// ==UserScript== // @name VOD Master (SOOP) // @namespace http://tampermonkey.net/ // @version 1.6.0.1 // @description SOOP 다시보기 타임스탬프 표시 및 다른 스트리머의 다시보기와 동기화 // @author AINukeHere // @match https://vod.sooplive.com/* // @match https://www.sooplive.com/* // @grant GM_xmlhttpRequest // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @grant GM_info // @run-at document-end // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/541829/VOD%20Master%20%28SOOP%29.user.js // @updateURL https://update.greasyfork.icu/scripts/541829/VOD%20Master%20%28SOOP%29.meta.js // ==/UserScript== (function() { 'use strict'; // 간소화된 로깅 함수 function logToExtension(...data) { console.debug(`[${new Date().toLocaleString()}]`, ...data); } function warnToExtension(...data) { logToExtension(...data); } function errorToExtension(...data) { logToExtension(...data); } function debugToExtension(...data) { logToExtension(...data); } if (window.top !== window.self) return; // 환경 구분용 전역 변수 (탬퍼몽키 환경) window.VODSync = window.VODSync || {}; window.VODSync.IS_TAMPER_MONKEY_SCRIPT = true; const GITHUB_RAW_URL = "https://raw.githubusercontent.com/AINukeHere/VOD-Master/main"; // 메인 페이지에서 실행되는 경우 (vod.sooplive.com) if (window.location.hostname === 'vod.sooplive.com') { class IVodSync { constructor(){ this.vodSyncClassName = this.constructor.name; this.debug('constructor() called'); } log(...data){ logToExtension(`[${this.vodSyncClassName}]`, ...data); } warn(...data){ warnToExtension(`[${this.vodSyncClassName}]`, ...data); } error(...data){ errorToExtension(`[${this.vodSyncClassName}]`, ...data); } debug(...data){ debugToExtension(`[${this.vodSyncClassName}]`, ...data); } } /** 요청 캐시 TTL (밀리초). 동일 요청은 이 시간 동안 캐시된 결과 반환 */ const REQUEST_CACHE_TTL_MS = 60 * 1000; const DEFAULT_SOOP_URLS = { VOD_ORIGIN: 'https://vod.sooplive.com', WWW_ORIGIN: 'https://www.sooplive.com', STBBS_ORIGIN: 'https://stbbs.sooplive.com', AFEVENT2_ORIGIN: 'https://afevent2.sooplive.com', LIVE_ORIGIN: 'https://live.sooplive.com', API_M_ORIGIN: 'https://api.m.sooplive.com', API_CHANNEL_ORIGIN: 'https://api-channel.sooplive.co.kr', SCH_ORIGIN: 'https://sch.sooplive.com', CHAPI_ORIGIN: 'https://chapi.sooplive.com', ST_ORIGIN: 'https://st.sooplive.com', RES_ORIGIN: 'https://res.sooplive.com', OGQ_STICKER_CDN_ORIGIN: 'https://ogq-sticker-global-cdn-z01.sooplive.com', OGQ_MARKET_ORIGIN: 'https://ogqmarket.sooplive.com', }; class SoopAPI extends IVodSync{ constructor(){ super(); this.SoopUrls = { ...DEFAULT_SOOP_URLS, ...(window.VODSync?.SoopUrls || {}) }; /** @type {Map} */ this._requestCache = new Map(); window.VODSync = window.VODSync || {}; window.VODSync.SoopUrls = this.SoopUrls; if (window.VODSync.soopAPI) { this.warn('[VODSync] SoopAPI가 이미 존재합니다. 기존 인스턴스를 덮어씁니다.'); } this.log('loaded'); window.VODSync.soopAPI = this; } /** * @param {string} key 캐시 키 * @returns {any|null} 캐시된 데이터 또는 null */ _getCached(key) { const entry = this._requestCache.get(key); if (!entry || Date.now() > entry.expiresAt) return null; return entry.data; } /** * @param {string} key 캐시 키 * @param {any} data 저장할 데이터 */ _setCache(key, data) { this._requestCache.set(key, { data, expiresAt: Date.now() + REQUEST_CACHE_TTL_MS }); } /** * 로그인 사용자 정보 조회(탬퍼몽키 환경에서 loginId 획득용). * @returns {Promise} */ async GetPrivateInfo() { const url = `${this.SoopUrls.AFEVENT2_ORIGIN}/api/get_private_info.php?_=${Date.now()}`; const cacheKey = 'GetPrivateInfo'; const cached = this._getCached(cacheKey); if (cached !== null) return cached; const res = await fetch(url, { headers: { accept: 'application/json, text/plain, */*', }, method: 'GET', mode: 'cors', credentials: 'include', }); if (res.status !== 200) return null; const b = await res.json(); this._setCache(cacheKey, b); return b; } /** * 채널 게시판 메뉴 조회. * @param {string} loginId * @returns {Promise} */ async GetStationMenu(loginId) { if (!loginId) return null; const lid = String(loginId); const cacheKey = `GetStationMenu:${lid}`; const cached = this._getCached(cacheKey); if (cached !== null) return cached; const url = `${this.SoopUrls.API_CHANNEL_ORIGIN}/v1.1/channel/${encodeURIComponent(lid)}/menu`; const res = await fetch(url, { headers: { accept: 'application/json, text/plain, */*', }, method: 'GET', mode: 'cors', credentials: 'include', }); if (res.status !== 200) return null; const b = await res.json(); this._setCache(cacheKey, b); return b; } _parseVodEditorCategoryScript(scriptText) { if (typeof scriptText !== 'string' || scriptText.length === 0) return null; const m = scriptText.match(/var\s+szVodCategory\s*=\s*(\{[\s\S]*\});?/); if (!m?.[1]) return null; try { return JSON.parse(m[1]); } catch (_e) { return null; } } /** * VOD 게시 카테고리 트리 조회(`vod_editor_category.js` 파싱). * @returns {Promise} */ async GetVodEditorCategory() { const cacheKey = 'GetVodEditorCategory:ko_KR'; const cached = this._getCached(cacheKey); if (cached !== null) return cached; const res = await fetch(`${this.SoopUrls.LIVE_ORIGIN}/script/locale/ko_KR/vod_editor_category.js`, { headers: { accept: '*/*', }, method: 'GET', mode: 'cors', credentials: 'include', }); if (res.status !== 200) return null; const txt = await res.text(); const parsed = this._parseVodEditorCategoryScript(txt); if (!parsed) return null; this._setCache(cacheKey, parsed); return parsed; } /** * @description Get Soop VOD Period * @param {number | string} videoId * @param {{ referer?: string }} [opts] — `referer` 생략 시 `https://vod.sooplive.com/player/{videoId}` * @returns {Promise} */ async GetSoopVodInfo(videoId, opts = {}) { const referer = typeof opts.referer === 'string' && opts.referer.length > 0 ? opts.referer : `${this.SoopUrls.VOD_ORIGIN}/player/${videoId}`; const cacheKey = `GetSoopVodInfo:${videoId}`; const cached = this._getCached(cacheKey); if (cached !== null) return cached; const a = await fetch(`${this.SoopUrls.API_M_ORIGIN}/station/video/a/view`, { "headers": { "accept": "application/json, text/plain, */*", "content-type": "application/x-www-form-urlencoded", "Referer": referer }, "body": `nTitleNo=${videoId}&nApiLevel=11&nPlaylistIdx=0`, "method": "POST", "credentials": "include" }); if (a.status !== 200){ return null; } const b = await a.json(); this._setCache(cacheKey, b); return b; } /** * stbbs `vodInfo.php?mode=web` VOD 메타 (다중 파일·총 길이 등). 타임라인 UI용. * @param {number | string} titleNo — 플레이어 `/player/{titleNo}` 과 동일 * @param {{ referer?: string }} [opts] — 생략 시 `https://vod.sooplive.com/player/{titleNo}` (공식 veditor Referer가 필요하면 명시) * @returns {Promise<{ result: number, message?: string, response?: object }|null>} */ async GetSoopVeditorWebVodInfo(titleNo, opts = {}) { const tn = String(titleNo); const referer = typeof opts.referer === 'string' && opts.referer.length > 0 ? opts.referer : `${this.SoopUrls.VOD_ORIGIN}/player/${tn}`; const cacheKey = `GetSoopVeditorWebVodInfo:${tn}`; const cached = this._getCached(cacheKey); if (cached !== null) return cached; const url = new URL(`${this.SoopUrls.STBBS_ORIGIN}/vodeditor/api/vodInfo.php`); url.searchParams.set('titleNo', tn); url.searchParams.set('mode', 'web'); const res = await fetch(url.toString(), { headers: { accept: 'application/json, text/plain, */*', Referer: referer, }, method: 'GET', credentials: 'include', mode: 'cors', }); if (res.status !== 200) { return null; } const b = await res.json(); this._setCache(cacheKey, b); return b; } async GetStreamerID(nickname){ const encodedNickname = encodeURI(nickname); const url = new URL(`${this.SoopUrls.SCH_ORIGIN}/api.php`); url.searchParams.set('m', 'bjSearch'); url.searchParams.set('v', '3.0'); url.searchParams.set('szOrder', 'score'); url.searchParams.set('szKeyword', encodedNickname); const cacheKey = `GetStreamerID:${url.toString()}`; const cached = this._getCached(cacheKey); if (cached !== null) return cached; this.log(`GetStreamerID: ${url.toString()}`); const res = await fetch(url.toString()); if (res.status !== 200){ return null; } const b = await res.json(); const userId = b.DATA[0]?.user_id ?? null; if (userId !== null) this._setCache(cacheKey, userId); return userId; } /** * @description Get Soop VOD List * @param {string} streamerId * @param {Date} start_date * @param {Date} end_date * @returns */ async GetSoopVOD_List(streamerId, start_date, end_date){ const start_date_str = start_date.toISOString().slice(0, 10).replace(/-/g, ''); const end_date_str = end_date.toISOString().slice(0, 10).replace(/-/g, ''); this.log(`start_date: ${start_date_str}, end_date: ${end_date_str}`); const url = new URL(`${this.SoopUrls.CHAPI_ORIGIN}/api/${streamerId}/vods/review`); url.searchParams.set("keyword", ""); url.searchParams.set("orderby", "reg_date"); url.searchParams.set("page", "1"); url.searchParams.set("field", "title,contents,user_nick,user_id"); url.searchParams.set("per_page", "60"); url.searchParams.set("start_date", start_date_str); url.searchParams.set("end_date", end_date_str); const cacheKey = `GetSoopVOD_List:${url.toString()}`; const cached = this._getCached(cacheKey); if (cached !== null) return cached; this.log(`GetSoopVOD_List: ${url.toString()}`); const res = await fetch(url.toString()); const b = await res.json(); this._setCache(cacheKey, b); return b; } /** * @description Get Chat Log for specific time range (playbackTime 기준) * @param {number | string} vodId * @param {number} startTime - 시작 시간 (초 단위, playbackTime) * @param {number} endTime - 끝 시간 (초 단위, playbackTime) * @returns {Promise} XML 문자열 또는 null */ async GetChatLog(vodId, startTime, endTime){ const vodInfo = await this.GetSoopVodInfo(vodId); if (vodInfo === null){ this.warn(`GetChatLog: GetSoopVodInfo failed: ${vodId}`); return null; } return this._GetChatLog(vodInfo, startTime, endTime); } /** * @description VOD 정보에서 startTime과 endTime이 속한 file을 찾아 chat 로그 가져오기 * @param {Object} vodInfo - VOD 정보 * @param {number} startTime - 시작 시간 (초 단위, playbackTime) * @param {number} endTime - 끝 시간 (초 단위, playbackTime) * @returns {Promise} XML 문자열 또는 null */ async _GetChatLog(vodInfo, startTime, endTime){ if (!vodInfo?.data?.files || vodInfo.data.files.length === 0) { this.warn("GetChatLog: files 정보가 없습니다."); return null; } // 각 file의 시작 시간과 끝 시간 계산 const fileRanges = []; let cumulativeTime = 0; for (const file of vodInfo.data.files) { const fileDuration = file.duration ? Math.floor(file.duration / 1000) : 0; // 밀리초를 초로 변환 const fileStart = cumulativeTime; const fileEnd = cumulativeTime + fileDuration; fileRanges.push({ file: file, start: fileStart, end: fileEnd, duration: fileDuration }); cumulativeTime += fileDuration; } // startTime과 endTime이 속한 file 찾기 const startFileIndex = fileRanges.findIndex(range => startTime >= range.start && startTime < range.end); let endFileIndex = fileRanges.findIndex(range => endTime >= range.start && endTime < range.end); // endTime이 마지막 파일의 끝을 넘어가는 경우, 마지막 파일로 설정 if (endFileIndex === -1 && fileRanges.length > 0) { const lastRange = fileRanges[fileRanges.length - 1]; if (endTime >= lastRange.end) { endFileIndex = fileRanges.length - 1; } } if (startFileIndex === -1) { this.warn(`GetChatLog: startTime ${startTime}초에 해당하는 file을 찾을 수 없습니다.`); return null; } if (endFileIndex === -1) { this.warn(`GetChatLog: endTime ${endTime}초에 해당하는 file을 찾을 수 없습니다.`); return null; } // 같은 파일 내에 있는 경우 if (startFileIndex === endFileIndex) { const fileRange = fileRanges[startFileIndex]; const relativeStartTime = startTime - fileRange.start; if (!fileRange.file.chat) { this.warn("GetChatLog: file에 chat URL이 없습니다."); return null; } const xml = await this._fetchChatLogFromFile(fileRange.file.chat, relativeStartTime); if (!xml) return null; // playbackTime 기준으로 변환 및 필터링 return this._convertAndFilterChatLogByTimeRange(xml, startTime, endTime, fileRange.start); } // 여러 파일에 걸쳐 있는 경우 const startFileRange = fileRanges[startFileIndex]; const endFileRange = fileRanges[endFileIndex]; if (!startFileRange.file.chat || !endFileRange.file.chat) { this.warn("GetChatLog: file에 chat URL이 없습니다."); return null; } // 앞 파일: 상대적 시작시간부터 파일 끝까지 const startFileRelativeStart = startTime - startFileRange.start; // 뒷 파일: 파일 시작부터 상대적 끝시간까지 const endFileRelativeStart = 0; // 두 파일에서 각각 가져오기 const [startFileXml, endFileXml] = await Promise.all([ this._fetchChatLogFromFile(startFileRange.file.chat, startFileRelativeStart), this._fetchChatLogFromFile(endFileRange.file.chat, endFileRelativeStart) ]); // XML 합치기 let mergedXml = null; if (!startFileXml && !endFileXml) { return null; } else if (!startFileXml) { mergedXml = endFileXml; } else if (!endFileXml) { mergedXml = startFileXml; } else { mergedXml = this._mergeChatLogXml(startFileXml, endFileXml); } if (!mergedXml) return null; // 여러 파일에 걸쳐 있으므로 각 파일의 시작 시간을 고려하여 변환 및 필터링 // 앞 파일의 채팅만 변환 및 필터링 let filteredStartXml = null; if (startFileXml) { filteredStartXml = this._convertAndFilterChatLogByTimeRange(startFileXml, startTime, endTime, startFileRange.start); } // 뒷 파일의 채팅만 변환 및 필터링 let filteredEndXml = null; if (endFileXml) { filteredEndXml = this._convertAndFilterChatLogByTimeRange(endFileXml, startTime, endTime, endFileRange.start); } // 필터링된 XML 합치기 if (!filteredStartXml && !filteredEndXml) { return null; } else if (!filteredStartXml) { return filteredEndXml; } else if (!filteredEndXml) { return filteredStartXml; } else { return this._mergeChatLogXml(filteredStartXml, filteredEndXml); } } /** * @description 특정 파일의 chat URL에서 chat 로그 가져오기 * @param {string} chatUrl - chat URL * @param {number} relativeStartTime - 파일 내 상대적 시작 시간 (초) * @returns {Promise} XML 문자열 또는 null */ async _fetchChatLogFromFile(chatUrl, relativeStartTime) { try { const baseUrl = new URL(chatUrl); baseUrl.searchParams.set("startTime", relativeStartTime); const url = baseUrl.toString(); const cacheKey = `_fetchChatLogFromFile:${url}`; const cached = this._getCached(cacheKey); if (cached !== null) return cached; const res = await fetch(url); if (res.status !== 200) { this.warn(`GetChatLog: HTTP ${res.status} - ${url}`); return null; } const xmlText = await res.text(); this._setCache(cacheKey, xmlText); return xmlText; } catch (error) { this.error("GetChatLog: fetch 오류:", error); return null; } } /** * @description XML에서 file 기준 timestamp를 전역 playbackTime으로 변환하고 특정 시간 범위의 채팅만 필터링 * @param {string} xml - XML 문자열 * @param {number} startTime - 시작 시간 (playbackTime, 초) * @param {number} endTime - 끝 시간 (playbackTime, 초) * @param {number} fileStartTime - 파일의 시작 시간 (playbackTime, 초) * @returns {string} 변환 및 필터링된 XML 문자열 */ _convertAndFilterChatLogByTimeRange(xml, startTime, endTime, fileStartTime) { try { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'text/xml'); // 파싱 오류 확인 const parseError = doc.querySelector('parsererror'); if (parseError) { this.error("GetChatLog: XML 파싱 오류", parseError.textContent); return xml; // 원본 반환 } const root = doc.documentElement; const chats = root.querySelectorAll('chat, ogq'); // 변환 및 필터링: 각 채팅의 타임스탬프를 playbackTime으로 변환하여 저장하고 범위 확인 chats.forEach(chat => { const tTag = chat.querySelector('t'); if (!tTag) { // 타임스탬프가 없으면 제거 chat.remove(); return; } const relativeTimestamp = parseFloat(tTag.textContent); if (isNaN(relativeTimestamp)) { // 타임스탬프가 유효하지 않으면 제거 chat.remove(); return; } // 파일 내 상대적 시간을 playbackTime으로 변환 const playbackTime = fileStartTime + relativeTimestamp; // startTime과 endTime 사이에 있지 않으면 제거 if (playbackTime < startTime || playbackTime > endTime) { chat.remove(); return; } // 태그의 값을 playbackTime으로 업데이트 tTag.textContent = playbackTime.toString(); }); // XML 문자열로 변환 const serializer = new XMLSerializer(); return serializer.serializeToString(doc); } catch (error) { this.error("GetChatLog: XML 변환 및 필터링 오류:", error); // 변환 및 필터링 실패 시 원본 반환 return xml; } } /** * @description 두 XML 문자열을 합치기 * @param {string} xml1 - 첫 번째 XML * @param {string} xml2 - 두 번째 XML * @returns {string} 합쳐진 XML */ _mergeChatLogXml(xml1, xml2) { try { const parser = new DOMParser(); const doc1 = parser.parseFromString(xml1, 'text/xml'); const doc2 = parser.parseFromString(xml2, 'text/xml'); // 파싱 오류 확인 const parseError1 = doc1.querySelector('parsererror'); const parseError2 = doc2.querySelector('parsererror'); if (parseError1 || parseError2) { this.error("GetChatLog: XML 파싱 오류", parseError1?.textContent || parseError2?.textContent); return xml1; // 첫 번째 XML 반환 } const root1 = doc1.documentElement; const root2 = doc2.documentElement; // 두 번째 XML의 chat/ogq 태그들을 첫 번째 XML에 추가 const chats2 = root2.querySelectorAll('chat, ogq'); chats2.forEach(chat => { const importedChat = doc1.importNode(chat, true); root1.appendChild(importedChat); }); // XML 문자열로 변환 const serializer = new XMLSerializer(); return serializer.serializeToString(doc1); } catch (error) { this.error("GetChatLog: XML 병합 오류:", error); // 병합 실패 시 첫 번째 XML 반환 return xml1; } } async GetEmoticon(){ const cacheKey = `GetEmoticon:${this.SoopUrls.ST_ORIGIN}/api/emoticons.php`; const cached = this._getCached(cacheKey); if (cached !== null) return cached; const res = await fetch(`${this.SoopUrls.ST_ORIGIN}/api/emoticons.php`); if (res.status !== 200){ return null; } const b = await res.json(); this._setCache(cacheKey, b); return b; } async GetSignitureEmoticon(streamerId){ const cacheKey = `GetSignitureEmoticon:${streamerId}`; const cached = this._getCached(cacheKey); if (cached !== null) return cached; const res = await fetch(`${this.SoopUrls.LIVE_ORIGIN}/api/signature_emoticon_api.php`, { "headers": { "accept": "*/*", "content-type": "application/x-www-form-urlencoded" }, "body": `work=list&szBjId=${streamerId}&nState=2&v=tier`, "method": "POST" }); if (res.status !== 200){ return null; } const b = await res.json(); this._setCache(cacheKey, b); return b; } /** * 다시보기 편집 VOD 생성 (setWebEditorJob). * @param {object} [opts] * @param {string} [opts.titleNo] * @param {string} [opts.broadNo] * @param {string} [opts.bbsNo] * @param {string} [opts.category] * @param {string} [opts.vodCategory] * @param {string} [opts.title] * @param {string} [opts.contents] * @param {string} [opts.hotissue] * @param {string} [opts.strmLangType] * @param {string|number} [opts.editType] * @param {Array} [opts.editJobInfo] edit_job_info 배열 * @param {string} [opts.referer] HTTP Referer (생략 시 VOD 플레이어 페이지) * @returns {Promise} */ async SetWebEditorJob(opts = {}) { const { titleNo, broadNo, bbsNo, referer: refererOpt, category = '00210000', vodCategory = '00820000', title = '', contents = '', hotissue = 'N', strmLangType = 'ko_KR', editType = '1', editJobInfo = [], } = opts; const referer = typeof refererOpt === 'string' && refererOpt.length > 0 ? refererOpt : `${this.SoopUrls.VOD_ORIGIN}/player/${String(titleNo)}`; if (!titleNo || !broadNo || !bbsNo) { this.error('SetWebEditorJob: titleNo, broadNo, bbsNo 필수'); return null; } const form = new FormData(); form.append('edit_job_info', JSON.stringify(editJobInfo)); form.append('edit_type', String(editType)); form.append('title_no', String(titleNo)); form.append('broad_no', String(broadNo)); form.append('bbsNo', String(bbsNo)); form.append('category', category); form.append('vod_category', vodCategory); form.append('title', title); form.append('contents', contents); form.append('hotissue', hotissue); form.append('strmLangType', strmLangType); const debugFormEntries = []; for (const [k, v] of form.entries()) { debugFormEntries.push([k, typeof v === 'string' ? v : '[binary]']); } const debugPayload = { url: `${this.SoopUrls.STBBS_ORIGIN}/vodeditor/api/setWebEditorJob.php`, method: 'POST', credentials: 'include', headers: { Accept: 'application/json, text/plain, */*', Referer: referer, }, formData: debugFormEntries, }; console.debug('[VODSync][SetWebEditorJob] request preview', debugPayload); if (false) { this.warn('SetWebEditorJob: debug-only 모드로 실제 전송하지 않았습니다.'); return { debugOnly: true, ...debugPayload, }; } const res = await fetch(`${this.SoopUrls.STBBS_ORIGIN}/vodeditor/api/setWebEditorJob.php`, { method: 'POST', credentials: 'include', headers: { Accept: 'application/json, text/plain, */*', Referer: referer, }, body: form, }); if (res.status !== 200) { this.error('SetWebEditorJob HTTP', res.status); return null; } return res.json(); } } class TimestampManagerBase extends IVodSync { constructor() { super(); this.videoTag = null; this.timeStampDiv = null; this.isEditing = false; this.request_vod_ts = null; this.request_real_ts = null; this.isControllableState = false; this.lastMouseMoveTime = Date.now(); this.isVisible = true; this.isHideCompletly = false; // 툴팁 숨기기 상태 // VODSync 네임스페이스에 자동 등록 window.VODSync = window.VODSync || {}; if (window.VODSync.tsManager) { this.warn('[VODSync] TimestampManager가 이미 존재합니다. 기존 인스턴스를 덮어씁니다.'); } window.VODSync.tsManager = this; this.createTooltip(); this.observeDOMChanges(); this.setupMouseTracking(); this.listenBroadcastSyncEvent(); setInterval(() => { this.update(); }, 200); } createTooltip() { if (!this.tooltipContainer) { // 툴팁을 담는 컨테이너 생성 this.tooltipContainer = document.createElement("div"); this.tooltipContainer.style.position = "fixed"; this.tooltipContainer.style.bottom = "20px"; this.tooltipContainer.style.right = "20px"; this.tooltipContainer.style.display = "flex"; this.tooltipContainer.style.alignItems = "center"; this.tooltipContainer.style.gap = "5px"; this.tooltipContainer.style.zIndex = "1000"; // Sync 버튼 생성 this.syncButton = document.createElement("button"); this.syncButton.title = "열려있는 다른 vod를 이 시간대로 동기화"; this.syncButton.style.background = "none"; this.syncButton.style.border = "none"; this.syncButton.style.cursor = "pointer"; this.syncButton.style.width = "32px"; this.syncButton.style.height = "32px"; this.syncButton.style.padding = "0"; this.syncButton.style.opacity = "1"; this.syncButton.style.borderRadius = "8px"; this.syncButton.style.overflow = "hidden"; // 아이콘 이미지 추가 const iconImage = document.createElement("img"); if (window.VODSync?.IS_TAMPER_MONKEY_SCRIPT !== true){ iconImage.src = chrome.runtime.getURL("res/img/broadcastSync.png"); } else{ iconImage.src = "https://raw.githubusercontent.com/AINukeHere/VOD-Master/main/res/img/broadcastSync.png"; } iconImage.style.width = "100%"; iconImage.style.height = "100%"; iconImage.style.objectFit = "fill"; iconImage.style.borderRadius = "8px"; this.syncButton.appendChild(iconImage); this.syncButton.addEventListener('click', this.handleBroadcastSyncButtonClick.bind(this)); // 툴팁 div 생성 this.timeStampDiv = document.createElement("div"); this.timeStampDiv.style.background = "black"; this.timeStampDiv.style.color = "white"; this.timeStampDiv.style.padding = "8px 12px"; this.timeStampDiv.style.borderRadius = "5px"; this.timeStampDiv.style.fontSize = "14px"; this.timeStampDiv.style.whiteSpace = "nowrap"; this.timeStampDiv.style.display = "block"; this.timeStampDiv.style.opacity = "1"; this.timeStampDiv.contentEditable = "false"; this.timeStampDiv.title = "더블클릭하여 수정, 수정 후 Enter 키 누르면 적용"; // 컨테이너에 버튼과 툴팁 추가 this.tooltipContainer.appendChild(this.syncButton); this.tooltipContainer.appendChild(this.timeStampDiv); document.body.appendChild(this.tooltipContainer); this.timeStampDiv.addEventListener("dblclick", () => { this.timeStampDiv.contentEditable = "true"; this.timeStampDiv.focus(); this.isEditing = true; this.timeStampDiv.style.outline = "2px solid red"; this.timeStampDiv.style.boxShadow = "0 0 10px red"; // 편집 중일 때는 투명화 방지 this.showTooltip(); }); this.timeStampDiv.addEventListener("mouseup", (event) => { event.stopPropagation(); // 치지직의 경우 다른 요소의 이 이벤트가 blur를 호출하게하므로 차단 }); this.timeStampDiv.addEventListener("blur", () => { this.timeStampDiv.contentEditable = "false"; this.isEditing = false; this.timeStampDiv.style.outline = "none"; this.timeStampDiv.style.boxShadow = "none"; }); this.timeStampDiv.addEventListener("keydown", (event) => { // 편집 모드일 때만 이벤트 차단 if (this.isEditing) { // 숫자 키 (0-9) - 영상 점프 기능만 차단하고 텍스트 입력은 허용 if (/^[0-9]$/.test(event.key)) { // 영상 플레이어의 키보드 이벤트만 차단 event.stopPropagation(); return; } // 방향키 - 영상 앞으로/뒤로 이동 기능 차단 if (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "ArrowLeft" || event.key === "ArrowRight") { event.stopPropagation(); return; } } // Enter 키 처리 if (event.key === "Enter") { event.preventDefault(); this.processTimestampInput(this.timeStampDiv.innerText.trim()); this.timeStampDiv.contentEditable = "false"; this.timeStampDiv.blur(); this.isEditing = false; return; } }); // 복사 이벤트 처리 - 텍스트만 복사되도록 this.timeStampDiv.addEventListener("copy", (event) => { const selectedText = window.getSelection().toString(); if (selectedText) { event.clipboardData.setData("text/plain", selectedText); event.preventDefault(); } }); } } update(){ if (!this.tooltipContainer){ this.log('timestamp 컨테이너가 없어 재생성합니다'); this.createTooltip(); } this.updateTooltip(); this.checkMouseState(); if (this.tooltipContainer.parentElement === document.body || !this.tooltipContainer.isConnected){ this.log('timestamp 컨테이너가 분리되어 재배치합니다'); if (this.moveTooltipToCtrlBox()) this.log('timestamp 컨테이너 재배치 성공'); else this.log('timestamp 컨테이너 재배치 실패'); } } // request_real_ts 가 null이면 request_vod_ts로 동기화하고 null이 아니면 동기화시도하는 시점과 request_real_ts와의 차이를 request_vod_ts와 더하여 동기화합니다. // 즉, 페이지가 로딩되는 동안의 시차를 적용할지 안할지 결정합니다. RequestGlobalTSAsync(request_vod_ts, request_real_ts = null){ this.request_vod_ts = request_vod_ts; this.request_real_ts = request_real_ts; } RequestLocalTSAsync(request_local_ts){ this.request_local_ts = request_local_ts; } listenBroadcastSyncEvent() { if (window.VODSync?.IS_TAMPER_MONKEY_SCRIPT !== true){ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === 'broadCastSync') { this.moveToGlobalTS(message.request_vod_ts, false); sendResponse({ success: true }); } return true; }); } else{ this.channel = new BroadcastChannel('vod-master'); this.channel.onmessage = (event) => { if (event.data.action === 'broadCastSync') { this.moveToGlobalTS(event.data.request_vod_ts, false); } } } } setupMouseTracking() { // 마우스 움직임 감지 - 시간만 업데이트 document.addEventListener('mousemove', () => { if (this.isHideCompletly) return; this.lastMouseMoveTime = Date.now(); this.showTooltip(); }); // 마우스가 페이지 밖으로 나갈 때 툴팁 숨기기 document.addEventListener('mouseleave', () => { this.hideTooltip(); }); } showTooltip() { if (this.timeStampDiv) { this.timeStampDiv.style.transition = 'opacity 0.3s ease-in-out'; this.timeStampDiv.style.opacity = '1'; this.isVisible = true; } if (this.syncButton) { this.syncButton.style.transition = 'opacity 0.3s ease-in-out'; this.syncButton.style.opacity = '1'; } } hideTooltip() { if (this.timeStampDiv && !this.isEditing) { this.timeStampDiv.style.transition = 'opacity 0.5s ease-in-out'; this.timeStampDiv.style.opacity = '0'; this.isVisible = false; } if (this.syncButton) { this.syncButton.style.transition = 'opacity 0.5s ease-in-out'; this.syncButton.style.opacity = '0'; } } handleBroadcastSyncButtonClick(e) { const request_vod_ts = this.getCurDateTime(); if (!request_vod_ts) { this.warn("현재 재생 중인 VOD의 라이브 당시 시간을 가져올 수 없습니다. 전역 동기화 실패."); return; } e.stopPropagation(); if (window.VODSync?.IS_TAMPER_MONKEY_SCRIPT !== true){ try{ chrome.runtime.sendMessage({action: 'broadCastSync', request_vod_ts: request_vod_ts.getTime()}); } catch (error) { console.warn('[VOD Master] 전역 동기화 요청 실패. 확장프로그램이 리로드되었거나 비활성화된 것 같습니다. 페이지를 새로고침하십시오.', error); } } else{ this.channel.postMessage({action: 'broadCastSync', request_vod_ts: request_vod_ts.getTime()}); } } updateTooltip() { if (!this.timeStampDiv || this.isEditing) return; const dateTime = this.getCurDateTime(); if (dateTime) { this.isControllableState = true; this.timeStampDiv.innerText = dateTime.toLocaleString("ko-KR"); } if (this.isPlaying() === true) { // 전역 시간 동기화 요청 체크 if (this.request_vod_ts != null){ const streamPeriod = this.getStreamPeriod(); if (streamPeriod){ if (this.request_real_ts == null){ this.log("시차 적용하지않고 동기화 시도"); if (!this.moveToGlobalTS(this.request_vod_ts, false)){ window.close(); } } else{ const currentSystemTime = Date.now(); const timeDifference = currentSystemTime - this.request_real_ts; this.log("시차 적용하여 동기화 시도. 시차: " + timeDifference); const adjustedGlobalTS = this.request_vod_ts + timeDifference; if (!this.moveToGlobalTS(adjustedGlobalTS, false)){ window.close(); } } this.request_vod_ts = null; this.request_real_ts = null; } } // 로컬 시간 동기화 요청 체크 if (this.request_local_ts != null){ this.log("playback time으로 동기화 시도"); if (!this.moveToPlaybackTime(this.request_local_ts, false)){ this.log('동기화 실패. 창을 닫습니다.'); window.close(); } this.request_local_ts = null; } } } checkMouseState(){ if (this.isHideCompletly) return; const currentTime = Date.now(); const timeSinceLastMove = currentTime - this.lastMouseMoveTime; // 2초 이상 마우스가 움직이지 않았고, 편집 중이 아니면 툴팁 숨기기 if (timeSinceLastMove >= 2000 && !this.isEditing && this.isVisible) { this.hideTooltip(); } } // 활성화/비활성화 메서드 enable() { this.isHideCompletly = false; if (this.tooltipContainer) { this.tooltipContainer.style.display = 'flex'; } this.log('툴팁 나타남'); } disable() { this.isHideCompletly = true; if (this.tooltipContainer) { this.tooltipContainer.style.display = 'none'; } this.log('툴팁 숨김'); } processTimestampInput(input) { const match = input.match(/(\d{4})\.\s*(\d{1,2})\.\s*(\d{1,2})\.\s*(오전|오후)\s*(\d{1,2}):(\d{2}):(\d{2})/); if (!match) { alert("유효한 타임스탬프 형식을 입력하세요. (예: 2024. 10. 22. 오전 5:52:55)"); return; } let [_, year, month, day, period, hour, minute, second] = match; year = parseInt(year); month = parseInt(month) - 1; // JavaScript의 Date는 0부터 시작하는 월을 사용 day = parseInt(day); hour = parseInt(hour); minute = parseInt(minute); second = parseInt(second); // 오전/오후 변환 if (period === "오후" && hour !== 12) { hour += 12; } else if (period === "오전" && hour === 12) { hour = 0; } const globalDateTime = new Date(year, month, day, hour, minute, second); if (isNaN(globalDateTime.getTime())) { alert("유효한 날짜로 변환할 수 없습니다."); return; } this.moveToGlobalTS(globalDateTime.getTime()); } /** * @description 전역 시간으로 영상 시간 맞춤 * @param {number} globalTS * @param {boolean} doAlert * @returns */ moveToGlobalTS(globalTS, doAlert = true) { const streamPeriod = this.getStreamPeriod(); if (!streamPeriod) { if (doAlert) { alert("VOD 정보를 가져올 수 없습니다."); } return false; } const [streamStartDateTime, streamEndDateTime] = streamPeriod; const globalDateTime = new Date(parseInt(globalTS)); if (streamStartDateTime > globalDateTime || globalDateTime > streamEndDateTime) { if (doAlert) { alert("입력한 타임스탬프가 방송 기간 밖입니다."); } return false; } const playbackTime = Math.floor((globalDateTime.getTime() - streamStartDateTime.getTime()) / 1000); return this.moveToPlaybackTime(playbackTime, doAlert); } // 플랫폼별로 구현해야 하는 추상 메서드들 observeDOMChanges() { throw new Error("observeDOMChanges must be implemented by subclass"); } getCurDateTime() { throw new Error("getCurDateTime must be implemented by subclass"); } getStreamPeriod() { throw new Error("getStreamPeriod must be implemented by subclass"); } /** * @description 재생 시점(초)을 전역 시각(global time)으로 변환. 파생 클래스에서 구현. * @param {number} totalPlaybackSec VOD 재생 시점(초) * @returns {Date|null} 전역 시각 또는 변환 불가 시 null */ playbackTimeToGlobalTS(totalPlaybackSec) { throw new Error("playbackTimeToGlobalTS must be implemented by subclass"); } // 현재 재생 중인지 여부를 반환하는 추상 메서드 isPlaying() { throw new Error("isPlaying must be implemented by subclass"); } /** * 전역 타임스탬프(ms) → 재생 시각(초) 변환이 가능한지 여부. * 타임라인 동기화 미리보기 등에서 변환 준비가 됐을 때만 사용. 서브클래스에서 오버라이드. * @returns {boolean} */ canConvertGlobalTSToPlaybackTime() { throw new Error("canConvertGlobalTSToPlaybackTime must be implemented by subclass"); } /** * @description 영상 시간을 설정 * @param {number} playbackTime * @param {boolean} doAlert */ moveToPlaybackTime(playbackTime, doAlert = true) { throw new Error("moveToPlaybackTime must be implemented by subclass"); } moveTooltipToCtrlBox(){ throw new Error("moveTooltipToCtrlBox must be implemented by subclass"); } } // TamperMonkey 환경은 페이지와 같은 월드이므로 실제 vodCore를 그대로 반환한다. window.VODSync.getVodCore = () => { if (typeof unsafeWindow === 'undefined') return null; const vc = unsafeWindow.vodCore; return vc && typeof vc === 'object' ? vc : null; }; const MAX_DURATION_DIFF = 30*1000; class SoopTimestampManager extends TimestampManagerBase { constructor() { super(); this.vodInfo = null; this.playTimeTag = null; this.isEditedVod = false; // 다시보기의 일부분이 편집된 상태인가 this.timeLink = null; /** @type {ReturnType|null} ghost 없을 때 time_link 폴백용 */ this._timeLinkJumpIntervalId = null; this.debug('loaded'); this.reloadingAll = false; // 현재 VOD 정보와 태그를 업데이트 중인가 this.loop_playing = false; } /** * vodCore 페이지 브리지 ghost (`#__vs_vodcore_ghost`). 브리지 미주입 시 null. * @returns {HTMLElement|null} */ _getVodCoreGhost() { return window.VODSync?.vodCoreBridge?.getGhost?.() ?? null; } update(){ super.update(); this.simpleLoopSettingUpdate(); // VOD 변경 감지 const url = new URL(window.location.href); const match = url.pathname.match(/\/player\/(\d+)/); const curVideoId = match[1]; if (this.vodInfo === null || curVideoId !== this.vodInfo.id){ this.log('VOD 변경 감지됨! 요소 업데이트 중...'); this.reloadAll(curVideoId); } } moveTooltipToCtrlBox(){ const ctrlBox = document.querySelector('.ctrlBox'); const rightCtrl = document.querySelector('.right_ctrl'); if (ctrlBox && rightCtrl && this.tooltipContainer) { ctrlBox.insertBefore(this.tooltipContainer, rightCtrl); this.tooltipContainer.style.position = ''; this.tooltipContainer.style.bottom = ''; this.tooltipContainer.style.right = ''; return true; } return false; } simpleLoopSettingUpdate(){ const LABEL_TEXT = '반복 재생'; const EM_TEXT_IDLE = '(added by VOD Master)'; // 반복재생 설정이 켜져있고 비디오 태그를 찾은 경우 if (this.videoTag !== null && this.loop_playing){ // 현재 재생 시간이 영상 전체 재생 시간과 같은 경우 처음으로 이동 if (this.getCurPlaybackTime() === Math.floor(this.vodInfo.total_file_duration / 1000)){ this.moveToPlaybackTime(0); // 비디오 태그가 일시정지 상태인 경우 재생 if (this.videoTag.paused){ this.videoTag.play(); } } } //반복 재생 설정 메뉴 추가 로직 const settingList = document.querySelector('.setting_list'); if (!settingList) return; // 설정 창을 열지 않음. if (settingList.classList.contains('subLayer_on')) return; // 서브 레이어가 열려있으면 추가하지 않음. const ul = settingList.childNodes[0]; const _exists = ul.querySelector('#VODSync'); if (_exists) return; // 이미 추가되어 있음. const li = document.createElement('li'); li.className = 'switchBtn_wrap loop_playing'; li.id = 'VODSync'; const label = document.createElement('label'); label.for = 'loop_playing'; label.innerText = LABEL_TEXT; const em = document.createElement('em'); em.innerText = EM_TEXT_IDLE; em.style.color = '#c7cad1'; // em.style.fontSize = '12px'; const input = document.createElement('input'); input.type = 'checkbox'; input.id = 'loop_playing'; input.checked = this.loop_playing; input.addEventListener('change',()=> { const a = document.querySelector('#VODSync input'); this.loop_playing = a.checked; if (this.loop_playing){ const autoPlayInput = document.querySelector('#autoplayChk'); if (autoPlayInput && autoPlayInput.checked){ autoPlayInput.click(); } } this.debug('loop_playing: ', this.loop_playing); }); const span = document.createElement('span'); label.appendChild(em); label.appendChild(input); label.appendChild(span); li.appendChild(label); ul.appendChild(li); } async loadVodInfo(videoId){ const vodInfo = await window.VODSync.soopAPI.GetSoopVodInfo(videoId); if (!vodInfo || !vodInfo.data) return; this.vodInfo = { id: videoId, type: vodInfo.data.file_type, files: vodInfo.data.files, total_file_duration: vodInfo.data.total_file_duration, originVodInfo: null, // 원본 다시보기의 정보 } if (vodInfo.data.write_tm){ const splitres = vodInfo.data.write_tm.split(' ~ '); this.vodInfo.startDate = new Date(splitres[0]); this.vodInfo.endDate = splitres[1] ? new Date(splitres[1]) : null; } // 클립은 라이브나 다시보기에서 생성될 수 있고 캐치는 클립에서도 생성될 수 있음. // 현재 페이지가 클립이거나 캐치인 경우 원본 VOD의 정보를 읽음 if (this.vodInfo.type === 'NORMAL'){ return; } else if (this.vodInfo.type === 'CLIP' || this.vodInfo.type === 'CATCH'){ if (vodInfo.data.original_clip_scheme){ const searchParamsStr = vodInfo.data.original_clip_scheme.split('?')[1]; const params = new URLSearchParams(searchParamsStr); const originVodType = params.get('type'); const originVodId = params.get('title_no'); const originVodChangeSecond = parseInt(params.get('changeSecond')); const originVodInfo = await window.VODSync.soopAPI.GetSoopVodInfo(originVodId); if (originVodInfo && originVodInfo.data){ const splitres = originVodInfo.data.write_tm.split(' ~ '); // 원본 VOD가 다시보기인 경우 원본 VOD의 정보를 읽음 if (originVodType === 'REVIEW'){ this.vodInfo.originVodInfo = { type: originVodInfo.data.file_type, startDate: new Date(splitres[0]), endDate: new Date(splitres[1]), files: originVodInfo.data.files, total_file_duration: originVodInfo.data.total_file_duration, originVodChangeSecond: originVodChangeSecond, // 원본 다시보기에서 현재 vod의 시작 시점의 시작 시간 } this.vodInfo.startDate = new Date(this.vodInfo.originVodInfo.startDate.getTime() + originVodChangeSecond * 1000); this.vodInfo.endDate = new Date(this.vodInfo.startDate.getTime() + this.vodInfo.total_file_duration); } // 원본 VOD가 클립인 경우 클립의 원본 VOD(다시보기) 정보를 읽음 else if (originVodType === 'CLIP'){ if (originVodInfo.data.original_clip_scheme){ const searchParamsStr = originVodInfo.data.original_clip_scheme.split('?')[1]; const params = new URLSearchParams(searchParamsStr); const originOriginVodType = params.get('type'); if (originOriginVodType === 'REVIEW'){ const originOriginVodId = params.get('title_no'); const originOriginVodChangeSecond = parseInt(params.get('changeSecond')); const originOriginVodInfo = await window.VODSync.soopAPI.GetSoopVodInfo(originOriginVodId); if (originOriginVodInfo && originOriginVodInfo.data){ const splitres = originOriginVodInfo.data.write_tm.split(' ~ '); this.vodInfo.originVodInfo = { type: originOriginVodInfo.data.file_type, startDate: new Date(splitres[0]), endDate: new Date(splitres[1]), files: originOriginVodInfo.data.files, total_file_duration: originOriginVodInfo.data.total_file_duration, originVodChangeSecond: originVodChangeSecond + originOriginVodChangeSecond, // 원본 다시보기에서 현재 vod의 시작 시점의 시작 시간 }; this.vodInfo.startDate = new Date(this.vodInfo.originVodInfo.startDate.getTime() + (originVodChangeSecond+originOriginVodChangeSecond) * 1000); this.vodInfo.endDate = new Date(this.vodInfo.startDate.getTime() + this.vodInfo.total_file_duration); } } else{ this.warn(`${this.videoId}를 제보해주시기 바랍니다.\n[VOD Master 설정] > [문의하기]`); } } } } } else{ this.vodInfo.startDate = null; this.vodInfo.endDate = null; this.log('원본 다시보기와 연결되어 있지 않은 VOD입니다.'); return; } } else if (this.vodInfo.type === 'EDITOR'){ this.vodInfo.startDate = null; this.vodInfo.endDate = null; this.log('편집된 VOD입니다.'); return; } const calcedTotalDuration = this.vodInfo.endDate.getTime() - this.vodInfo.startDate.getTime(); const durationDiff = Math.abs(calcedTotalDuration - this.vodInfo.total_file_duration); this.debug('오차: ', durationDiff); if (durationDiff < MAX_DURATION_DIFF){ this.isEditedVod = false; } else{ this.isEditedVod = true; this.log('영상 전체 재생 시간과 계산된 재생 시간이 다릅니다.'); } this.log('영상 정보 로드 완료'); } async reloadAll(videoId){ if (this.reloadingAll) return; this.reloadingAll = true; try { const time = this.vodInfo == null ? 0 : 1000; await new Promise(r => setTimeout(r, time)); await this.loadVodInfo(videoId); this.reloadVideoTag(); this.moveTooltipToCtrlBox(); } finally { this.reloadingAll = false; } } reloadVideoTag(){ this.playTimeTag = document.querySelector('span.time-current'); this.videoTag = document.querySelector('#video'); if (this.videoTag === null) this.videoTag = document.querySelector('#video_p'); if (this.playTimeTag === null) setTimeout(()=>{this.reloadVideoTag()}, 500); else if (this.videoTag === null){ this.log('playTimeTag 갱신됨', this.playTimeTag); setTimeout(()=>{this.reloadVideoTag()}, 500); } else{ this.log('videoTag 갱신됨', this.videoTag); } } /* override methods */ observeDOMChanges() { // const targetNode = document.body; // const config = { childList: true, subtree: true }; // this.observer = new MutationObserver(() => { // this.reloadAll(); // }); // this.observer.observe(targetNode, config); } getStreamPeriod(){ if (!this.vodInfo || this.vodInfo.type === 'NORMAL') return null; const startDate = this.vodInfo.originVodInfo === null ? this.vodInfo.startDate : this.vodInfo.originVodInfo.startDate; const endDate = this.vodInfo.originVodInfo === null ? this.vodInfo.endDate : this.vodInfo.originVodInfo.endDate; return [startDate, endDate]; } playbackTimeToGlobalTS(totalPlaybackSec){ if (!this.vodInfo) return null; const reviewStartDate = this.vodInfo.originVodInfo === null ? this.vodInfo.startDate : this.vodInfo.originVodInfo.startDate; const reviewDataFiles = this.vodInfo.originVodInfo === null ? this.vodInfo.files : this.vodInfo.originVodInfo.files; const deltaTimeSec = this.vodInfo.originVodInfo === null ? 0 : this.vodInfo.originVodInfo.originVodChangeSecond; // 시간오차가 임계값 이하이거나 다시보기 구성 파일이 1개인 경우 if (!this.isEditedVod || reviewDataFiles.length === 1){ return new Date(reviewStartDate.getTime() + (totalPlaybackSec + deltaTimeSec)*1000); } if (this.isEditedVod && reviewDataFiles.length > 1 && this.vodInfo.type !== 'REVIEW'){ this.warn(`${this.videoId}를 제보해주시기 바랍니다.\n[VOD Master 설정] > [문의하기]`); return null; } let cumulativeTime = 0; for (let i = 0; i < reviewDataFiles.length; ++i){ const file = reviewDataFiles[i]; const localPlaybackTime = totalPlaybackSec*1000 - cumulativeTime; const hour = Math.floor(localPlaybackTime / 3600000); const minute = Math.floor((localPlaybackTime % 3600000) / 60000); const second = Math.floor((localPlaybackTime % 60000) / 1000); // this.log(`localPlaybackTime: ${hour}:${minute}:${second}`); if (localPlaybackTime > file.duration){ cumulativeTime += file.duration; continue; } const startTime = new Date(file.file_start); return new Date(startTime.getTime() + localPlaybackTime); } return null; } globalTSToPlaybackTime(globalTS){ if (!this.vodInfo || !this.videoTag) return null; const reviewStartDate = this.vodInfo.originVodInfo === null ? this.vodInfo.startDate : this.vodInfo.originVodInfo.startDate; const reviewDataFiles = this.vodInfo.originVodInfo === null ? this.vodInfo.files : this.vodInfo.originVodInfo.files; const deltaTimeSec = this.vodInfo.originVodInfo === null ? 0 : this.vodInfo.originVodInfo.originVodChangeSecond; // 시간오차가 임계값 이하이거나 다시보기 구성 파일이 1개인 경우 if (!this.isEditedVod || reviewDataFiles.length === 1){ const temp = reviewStartDate.getTime(); const temp2 = (globalTS - temp) / 1000; return Math.floor(temp2) - deltaTimeSec; } if (this.isEditedVod && reviewDataFiles.length > 1 && this.vodInfo.type !== 'REVIEW'){ this.warn(`${this.videoId}를 제보해주시기 바랍니다.\n[VOD Master 설정] > [문의하기]`); return null; } let cumulativeTime = 0; for (let i = 0; i < reviewDataFiles.length; ++i){ const file = reviewDataFiles[i]; const fileStartDate = new Date(file.file_start); const fileEndDate = new Date(fileStartDate.getTime() + file.duration); if (fileStartDate.getTime() <= globalTS && globalTS <= fileEndDate.getTime()){ return Math.floor((globalTS - fileStartDate.getTime() + cumulativeTime) / 1000); } cumulativeTime += file.duration; } return null; } /** @override 전역 타임스탬프 → 재생 시각 변환 가능 여부 (vodInfo, videoTag 준비 시 true) */ canConvertGlobalTSToPlaybackTime() { return this.vodInfo != null; } /** * @override * @description 현재 영상이 스트리밍된 당시 시간을 반환 * @returns {Date} 현재 영상이 스트리밍된 당시 시간 * @returns {null} 영상 정보를 가져올 수 없음. 의도치않은 상황 발생 * @returns {string} 당시 시간을 계산하지 못한 오류 메시지. */ getCurDateTime(){ if (this.vodInfo == null) return null; const totalPlaybackSec = this.getCurPlaybackTime(); if (totalPlaybackSec === null) return null; if (this.vodInfo.type === 'NORMAL') return '업로드 VOD는 지원하지 않습니다.'; else if (this.vodInfo.type === "EDITOR") return '편집된 VOD는 지원하지 않습니다.'; if (this.vodInfo.startDate === null && this.vodInfo.endDate === null && this.vodInfo.originVodInfo === null) { return '원본 다시보기와 연결되어 있지 않은 VOD입니다.'; } const globalTS = this.playbackTimeToGlobalTS(totalPlaybackSec); return globalTS; } /** 다시보기·클립 등 API `files` 출처 (playbackTimeToGlobalTS와 동일). */ _reviewDataFilesForPlayback() { if (!this.vodInfo) return null; const files = this.vodInfo.originVodInfo === null ? this.vodInfo.files : this.vodInfo.originVodInfo.files; if (!Array.isArray(files) || files.length === 0) return null; return files; } /** 재생 표시 태그 정수 초 (HH:MM:SS / MM:SS). 다중 파일일 때 어느 file인지 골 때·비디오 없을 때 폴백. */ _parsePlayTimeTagToIntegerSec() { if (!this.playTimeTag) return null; const totalPlaybackTimeStr = this.playTimeTag.innerText.trim(); const splitres = totalPlaybackTimeStr.split(':'); let totalPlaybackSec = 0; if (splitres.length === 3) { totalPlaybackSec = parseInt(splitres[0], 10) * 3600 + parseInt(splitres[1], 10) * 60 + parseInt(splitres[2], 10); } else if (splitres.length === 2) { totalPlaybackSec = parseInt(splitres[0], 10) * 60 + parseInt(splitres[1], 10); } else { this.warn(`${this.videoId}를 제보해주시기 바랍니다.\n[VOD Master 설정] > [문의하기]`); return null; } return Number.isFinite(totalPlaybackSec) ? totalPlaybackSec : null; } /** * @description 현재 재생 시간을 초 단위로 반환 (전역 타임라인). `VODSync.getVodCore().playerController.playingTime` 우선. * `files[].duration`(ms)는 앞선 파일 길이만 ms로 누적 후 초로 바꾸고, **현재 파일 안**의 재생 위치는 항상 `videoTag.currentTime`만 쓴다. * 재생 표시 태그(`playTimeTag`)는 **몇 번째 파일인지** 고를 때만 쓰고, 재생 초의 소수·누적에는 섞지 않는다. 비디오를 읽을 수 없을 때만 태그 정수 초를 쓴다. * @returns {number} 현재 재생 시간(초) * @returns {null} 재생 시간을 계산할 수 없음. 의도치않은 상황 발생 */ getCurPlaybackTime() { const pa = window.VODSync?.getVodCore?.(); const pt = pa?.playerController?.playingTime; if (typeof pt === 'number' && Number.isFinite(pt)) return Math.max(0, pt); const v = this.videoTag; const maxSec = this.getTotalFileDurationSec(); const ct = v && Number.isFinite(v.currentTime) ? Math.max(0, v.currentTime) : null; const files = this._reviewDataFilesForPlayback(); if (files && files.length > 0 && ct != null) { if (files.length === 1) { const maxSec = this.getTotalFileDurationSec(); if (maxSec != null) return Math.min(maxSec, ct); return ct; } // playTimeTag를 사용하여 몇 번째 파일인지 판별, 이전 파일들의 duration을 누적 const T = this._parsePlayTimeTagToIntegerSec(); if (T === null) return null; const tagMs = T * 1000; let cumMs = 0; for (let i = 0; i < files.length; i++) { const durMs = files[i].duration; const endMs = cumMs + durMs; const isLast = i === files.length - 1; if (tagMs < endMs - 1e-6 || isLast) { let total = Math.floor(cumMs / 1000) + ct; // 무슨 이유에선지 SOOP의 플레이어에선 앞의 파일들의 합에서 소수점을 버림 const maxSec = this.getTotalFileDurationSec(); if (maxSec != null) total = Math.max(0, Math.min(maxSec, total)); else total = Math.max(0, total); return total; } cumMs = endMs; } } if (ct != null) { let total = ct; if (maxSec != null) total = Math.min(maxSec, total); return Math.max(0, total); } const tagSec = this._parsePlayTimeTagToIntegerSec(); if (tagSec === null) return null; let totalPlaybackSec = tagSec; if (maxSec != null) totalPlaybackSec = Math.max(0, Math.min(maxSec, totalPlaybackSec)); return totalPlaybackSec; } /** * GetSoopVodInfo 기반 전체 재생 길이(초). vodCore ghost·편집 패널 타임라인 스케일에 쓸 때 TamperMonkey 등에서 `