SOOP 다시보기 타임스탬프 표시 및 다른 스트리머의 다시보기와 동기화
// ==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
// ==/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<string, { data: any, expiresAt: number }>} */
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<object|null>}
*/
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<object|null>}
*/
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<object|null>}
*/
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<object|null>}
*/
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<string|null>} 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<string|null>} 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<string|null>} 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;
}
// <t> 태그의 값을 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<object|null>}
*/
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<typeof setInterval>|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 등에서 `<video>.duration` 대신 사용.
* @returns {number|null} 로드 전·비정상이면 null
*/
getTotalFileDurationSec() {
if (!this.vodInfo || !Number.isFinite(this.vodInfo.total_file_duration)) return null;
const ms = this.vodInfo.total_file_duration;
if (ms <= 0) return null;
return ms / 1000;
}
/**
* @override
* @description 영상 시간을 설정
* @param {number} globalTS (milliseconds)
* @param {boolean} doAlert
* @returns {boolean} 성공 여부
*/
async moveToGlobalTS(globalTS, doAlert = true) {
const playbackTime = await this.globalTSToPlaybackTime(globalTS);
if (playbackTime === null) return false;
const maxPlaybackTime = Math.floor(this.vodInfo.total_file_duration / 1000);
if (playbackTime < 0 || playbackTime > maxPlaybackTime){
const errorMessage = `재생 시간 범위를 벗어납니다. (${playbackTime < 0 ? playbackTime : playbackTime - maxPlaybackTime}초 초과됨)`;
if (doAlert)
alert(errorMessage);
this.warn(errorMessage);
return false;
}
return this.moveToPlaybackTime(playbackTime, doAlert);
}
moveToPlaybackTime(playbackTime, doAlert = true) {
if (this._timeLinkJumpIntervalId != null) {
clearInterval(this._timeLinkJumpIntervalId);
this._timeLinkJumpIntervalId = null;
}
const url = new URL(window.location.href);
const secNum = Math.max(0, Number(playbackTime));
const changeSec = Number.isFinite(secNum) ? Math.floor(secNum * 100) / 100 : 0;
url.searchParams.set('change_second', String(changeSec));
window.history.replaceState({}, '', url.toString());
const pa = window.VODSync?.getVodCore?.();
if (pa && typeof pa.seek === 'function') {
const sec = Math.max(0, Number(playbackTime));
try {
pa.seek(Number.isFinite(sec) ? sec : 0);
this.debug('playback seek via adapter', sec);
return true;
} catch (e) {
/* ignore */
}
}
/// soop 댓글 타임라인 기능 (adapter·시크 불가 시 폴백)
const targetSec = playbackTime;
this._timeLinkJumpIntervalId = setInterval(() => {
if (Math.abs(this.getCurPlaybackTime() - targetSec) <= 1) {
if (this._timeLinkJumpIntervalId != null) {
clearInterval(this._timeLinkJumpIntervalId);
this._timeLinkJumpIntervalId = null;
}
return;
}
if (this.timeLink === null) {
this.timeLink = document.createElement('a');
document.body.appendChild(this.timeLink);
}
this.timeLink.className = 'time_link';
this.timeLink.setAttribute('data-time', targetSec.toString());
this.timeLink.click();
this.debug('timeLink 클릭됨');
}, 500);
return true;
}
// 현재 재생 중인지 여부 반환
isPlaying() {
if (this.videoTag) {
return !this.videoTag.paused;
}
return false;
}
}
class VODLinkerBase extends IVodSync{
constructor(isInIframe = false){
super();
this.BTN_TEXT_IDLE = "Sync VOD";
this.SYNC_BUTTON_CLASSNAME = 'vodSync-sync-btn';
if (isInIframe){
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get('only_search') === '1'){
this.setupSearchAreaOnlyMode();
}
window.addEventListener('message', this.handleWindowMessage.bind(this));
this.getRequestVodDate = () => {return new Date(this.request_vod_ts);}
this.getRequestRealTS = () => {
if (this.request_real_ts){
return this.request_real_ts;
}
return null;
}
}
else{
this.getRequestVodDate = () => {return window.VODSync?.tsManager?.getCurDateTime();}
this.getRequestRealTS = () => {
if (window.VODSync?.tsManager?.isPlaying()){ // 재생 중인경우 페이지 로딩 시간을 보간하기위해 탭 연 시점을 전달
return Date.now();
}
return null;
}
}
this.startSyncButtonManagement();
this.setupSearchInputKeyboardHandler();
}
// 주기적으로 동기화 버튼 생성 및 업데이트
startSyncButtonManagement() {
setInterval(() => {
const requestDate = this.getRequestVodDate();
// 타임스탬프 매니저가 vod 정보를 불러오지 못한 경우 동기화 버튼 생성 안함
if (!this.isValidDate(requestDate)) return;
const targets = this.getTargetsForCreateSyncButton();
if (!targets) return;
targets.forEach(element => {
if (element.querySelector(`.${this.SYNC_BUTTON_CLASSNAME}`)) return; // 이미 동기화 버튼이 있음
const button = this.createSyncButton();
button.addEventListener('click', (e) => this.handleFindVODButtonClick(e, button));
element.appendChild(button);
});
}, 500);
}
// 동기화 버튼 onclick 핸들러
async handleFindVODButtonClick(e, button){
e.preventDefault(); // a 태그의 기본 이동 동작 막기
e.stopPropagation(); // 이벤트 버블링 차단
// 스트리머 ID 검색
const streamerName = this.getStreamerName(button);
if (!streamerName) {
alert("검색어를 찾을 수 없습니다.");
button.innerText = this.BTN_TEXT_IDLE;
return;
}
button.innerText = `${streamerName}로 ID 검색 중`;
const streamerId = await this.getStreamerId(streamerName);
if (!streamerId) {
alert(`${streamerName}의 스트리머 ID를 찾지 못했습니다.`);
button.innerText = this.BTN_TEXT_IDLE;
return;
}
this.debug(`스트리머 ID: ${streamerId}`);
const requestDate = this.getRequestVodDate();
const request_real_ts = this.getRequestRealTS();
if (!this.isValidDate(requestDate)){
this.warn("타임스탬프 정보를 받지 못했습니다.");
button.innerText = this.BTN_TEXT_IDLE;
return;
}
if (typeof requestDate === 'string'){
this.warn(requestDate);
button.innerText = this.BTN_TEXT_IDLE;
alert(requestDate);
return;
}
button.innerText = `${streamerName}의 VOD 검색 중...`;
const vodInfo = await this.findVodByDatetime(button, streamerId, streamerName, requestDate);
if (!vodInfo){
alert("동기화할 다시보기를 찾지 못했습니다.");
button.innerText = this.BTN_TEXT_IDLE;
return;
}
this.log(`다시보기 정보: ${vodInfo.vodLink}, ${vodInfo.startDate}, ${vodInfo.endDate}`);
const url = new URL(vodInfo.vodLink);
const change_second = Math.round((requestDate.getTime() - vodInfo.startDate.getTime()) / 1000);
url.searchParams.set('change_second', change_second);
url.searchParams.set('request_vod_ts', requestDate.getTime());
if (request_real_ts){
url.searchParams.set('request_real_ts', request_real_ts);
}
// 타임라인 댓글 동기화 요청 처리
const timelinePayload = (window.VODSync?.timelineCommentProcessor?.getTimelineSyncPayload?.() ?? []);
if (timelinePayload.length > 0) {
url.searchParams.set('timeline_sync', '1');
try {
localStorage.setItem('vodSync_timeline', JSON.stringify(timelinePayload));
} catch (_) { /* quota or disabled */ }
}
window.open(url, "_blank");
this.log(`VOD 링크: ${url.toString()}`);
button.innerText = this.BTN_TEXT_IDLE;
this.getSearchInputElement().blur();
this.closeSearchArea();
}
isValidDate(date){
return date instanceof Date && !isNaN(date.getTime());
}
// 상위 페이지에서 타임스탬프 정보를 받음 (other sync panel에서 iframe으로 열릴 때 사용)
handleWindowMessage(e){
if (e.data.response === "SET_REQUEST_VOD_TS"){
this.request_vod_ts = e.data.request_vod_ts;
this.request_real_ts = e.data.request_real_ts;
// this.log("REQUEST_VOD_TS 받음:", e.data.request_vod_ts, e.data.request_real_ts);
}
}
/**
* @description 검색 결과 페이지에서 검색 영역만 남기게 함. (other sync panel에서 iframe으로 열릴 때 사용)
*/
setupSearchAreaOnlyMode() {
document.documentElement.style.overflow = "hidden";
// 파생 클래스들이 오버라이드하여 구현하되 super.setupSearchAreaOnlyMode()를 호출해야함
}
/**
* @description 동기화 버튼을 생성할 요소를 반환
* @returns {NodeList} 동기화 버튼을 생성할 요소들
*/
getTargetsForCreateSyncButton(){
// 파생 클래스들이 오버라이드하여 구현해야함
throw new Error("Not implemented");
}
/**
* @description 동기화 버튼을 생성
* @returns {HTMLButtonElement} 동기화 버튼
*/
createSyncButton(){
// 파생 클래스들이 오버라이드하여 구현해야함
throw new Error("Not implemented");
}
/**
* @description 스트리머 이름을 반환
* @param {HTMLButtonElement} button 동기화 버튼
* @returns {string} 스트리머 이름
*/
getStreamerName(button){
// 파생 클래스들이 오버라이드하여 구현해야함
throw new Error("Not implemented");
}
/**
* @description 스트리머 ID를 반환
* @param {string} searchWord 검색어
* @returns {string} 스트리머 ID
*/
async getStreamerId(searchWord){
// 파생 클래스들이 오버라이드하여 구현해야함
throw new Error("Not implemented");
}
/**
* @description 다시보기를 찾음
* @param {HTMLButtonElement} button 동기화 버튼
* @param {string} streamerId 스트리머 ID
* @param {string} streamerName 스트리머 이름
* @param {Date} requestDate 요청 시간
* @returns {Object} {vodLink: string, startDate: Date, endDate: Date} or null
*/
async findVodByDatetime(button, streamerId, streamerName, requestDate) {
// 파생 클래스들이 오버라이드하여 구현해야함
throw new Error("Not implemented");
}
/**
* @description 색어를 제거하고 검색결과미리보기 영역을 닫음
*/
closeSearchArea(){
// 파생 클래스들이 오버라이드하여 구현해야함
throw new Error("Not implemented");
}
/**
* @description 검색창 요소를 반환
* @returns {HTMLInputElement|null} 검색창 input 요소
*/
getSearchInputElement(){
// 파생 클래스들이 오버라이드하여 구현해야함
return null;
}
/**
* @description 검색창에 키보드 이벤트 핸들러 설정 (Ctrl+Shift+Enter로 SyncVOD 버튼 클릭)
*/
setupSearchInputKeyboardHandler() {
// 검색창이 동적으로 생성될 수 있으므로 주기적으로 확인
setInterval(() => {
const searchInput = this.getSearchInputElement();
if (!searchInput) return;
// 이미 이벤트 리스너가 추가되어 있는지 확인
if (searchInput.dataset.vodSyncHandlerAdded === 'true') return;
searchInput.addEventListener('keydown', (e) => {
// Ctrl+Shift+Enter 감지
if (e.key === 'Enter' && e.ctrlKey && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const syncButton = document.querySelector(`.${this.SYNC_BUTTON_CLASSNAME}`);
if (syncButton) {
syncButton.click();
}
}
});
// 이벤트 리스너가 추가되었음을 표시
searchInput.dataset.vodSyncHandlerAdded = 'true';
}, 500);
}
}
class SoopVODLinker extends VODLinkerBase{
/**
* @description 검색 결과 페이지에서 검색 결과 영역만 남기고 나머지는 숨기게 함. (other sync panel에서 iframe으로 열릴 때 사용)
* @override
*/
setupSearchAreaOnlyMode() {
super.setupSearchAreaOnlyMode();
this.waitForGnbAndSearchArea();
}
async waitForGnbAndSearchArea() {
let allDone = true;
const gnb = document.querySelector('#soop-gnb');
const searchArea = document.querySelector('.topSearchArea');
const backBtn = document.querySelector('#topSearchArea > div > div > button');
const searchButton = document.querySelector('.btn-search');
if (gnb && searchArea && backBtn && searchButton)
{
// await new Promise(resolve => setTimeout(resolve, 1000));
Array.from(gnb.parentNode.children).forEach(sibling => {
if (sibling !== gnb) sibling.style.display = 'none';
});
searchArea.style.display = "flow";
Array.from(searchArea.parentNode.children).forEach(sibling => {
if (sibling !== searchArea) sibling.remove();
});
backBtn.style.display = "none";
document.body.style.background = 'white';
searchButton.click();
}
else
allDone = false;
if (!allDone) setTimeout(() => this.waitForGnbAndSearchArea(), 200);
}
getTargetsForCreateSyncButton(){
const targets = document.querySelectorAll('#areaSuggest > ul > li > a');
const filteredTargets = [];
for(const target of targets){
if (target.querySelector('em')) continue;
filteredTargets.push(target);
}
return filteredTargets;
}
createSyncButton(){
const button = document.createElement("button");
button.className = this.SYNC_BUTTON_CLASSNAME;
button.innerText = this.BTN_TEXT_IDLE;
button.style.background = "gray";
button.style.fontSize = "12px";
button.style.color = "white";
button.style.marginLeft = "20px";
button.style.padding = "5px";
button.style.verticalAlign = 'middle';
return button;
}
getStreamerName(button){
const nicknameSpan = button.parentElement.querySelector('span');
if (!nicknameSpan) return null;
return nicknameSpan.innerText;
}
// 검색어를 제거하고 검색결과미리보기 영역을 닫음
closeSearchArea(){
const searchPreviewCloseButton = document.querySelector('.srh_back'); // SOOP 검색 결과 영역 닫기 버튼
if (searchPreviewCloseButton) {
searchPreviewCloseButton.click();
}
const delSearcButton = document.querySelector('.del_text');
if (delSearcButton){
delSearcButton.click();
}
}
async getStreamerId(searchWord){
const streamerId = await window.VODSync.soopAPI.GetStreamerID(searchWord);
return streamerId;
}
/**
* @description 다시보기를 찾음
* @param {HTMLButtonElement} button 동기화 버튼
* @param {string} streamerId 스트리머 ID
* @param {string} streamerName 스트리머 이름
* @param {Date} requestDate
* @returns {Object} {vodLink: string, startDate: Date, endDate: Date} or null
* @override
*/
async findVodByDatetime(button, streamerId, streamerName, requestDate) {
const search_range_hours = 24*3;// +- 3일 동안 검색
const search_start_date = new Date(requestDate.getTime() - search_range_hours * 60 * 60 * 1000);
const search_end_date = new Date(requestDate.getTime() + search_range_hours * 60 * 60 * 1000);
const vodList = await window.VODSync.soopAPI.GetSoopVOD_List(streamerId, search_start_date, search_end_date);
const totalVodCount = vodList.data.length;
for(let i = 0; i < totalVodCount; ++i){
const vod = vodList.data[i];
button.innerText = `${streamerName}의 VOD 검색 중 (${i+1}/${totalVodCount})`;
const vodInfo = await window.VODSync.soopAPI.GetSoopVodInfo(vod.title_no);
if (vodInfo === null){
continue;
}
const period = vodInfo.data.write_tm;
const splitres = period.split(' ~ ');
const startDate = new Date(splitres[0]);
const endDate = new Date(splitres[1]);
if (startDate <= requestDate && requestDate <= endDate){
const vodOrigin = window.VODSync?.SoopUrls?.VOD_ORIGIN || 'https://vod.sooplive.com';
return{
vodLink: `${vodOrigin}/player/${vod.title_no}`,
startDate: startDate,
endDate: endDate
};
}
}
}
/**
* @description 검색창 요소를 반환
* @returns {HTMLInputElement|null} 검색창 input 요소
* @override
*/
getSearchInputElement(){
// SOOP 검색창 선택자 (검색 결과 페이지의 검색창)
const searchInput = document.querySelector('#search-inp');
return searchInput || null;
}
}
/**
* 다시보기 페이지의 타임라인 댓글을 인식하고, VOD linker가 동기화 시 참조할 수 있는 형태로 가공해 두는 클래스의 베이스.
* 댓글 컨테이너를 찾은 뒤 주기적으로 댓글 요소를 찾아 타임라인 댓글이면 변환 체크박스를 붙이고,
* 체크/해제 시 멤버 배열에 댓글 요소를 저장/제거해 두었다가 VOD linker 요청 시 가공해 전달한다.
*/
class TimelineCommentProcessorBase extends IVodSync {
static CHECKBOX_CLASS = 'vodSync-timeline-sync-cb';
static CHECKBOX_WRAP_CLASS = 'vodSync-timeline-sync-wrap';
/** 더보기 레이어(_moreDot_layer) 안에 넣는 편집 버튼 식별용 */
static EDIT_IN_MORE_CLASS = 'vodSync-timeline-edit-in-more';
static BTN_INSERT_CURRENT_TIME_CLASS = 'vodSync-timeline-insert-current-time';
static BTN_INSERT_CURRENT_TIME_LABEL = '현재 시간 삽입';
// ---- 문자열 리소스 (UI 노출용) ----
static LABEL_SYNC_TOOLTIP = '다른 스트리머의 다시보기가 동기화될 때 이 타임라인 댓글이 동기화된 다시보기에 맞춰 변환됩니다';
static LABEL_SYNC_CHECKBOX = '동기화할 때 이 타임라인을 변환';
static BTN_EDIT_IN_MORE = '타임라인 편집';
static BTN_EDIT_IN_MORE_TOOLTIP = '이 타임라인 댓글을 편집기에서 편집·복사합니다';
static PANEL_HEADER = '타임라인 편집기';
static BTN_COLLAPSE = '접기';
static BTN_EXPAND = '펴기';
static BTN_COPY = '전체 복사';
static BTN_COPIED = '복사됨';
static BTN_COPY_FAILED = '복사 실패';
static BTN_CLOSE = '닫기';
static TIME_PLACEHOLDER = '--:--';
static BTN_TIME_MINUS = '-';
static BTN_TIME_PLUS = '+';
/**
* 자식 클래스에서 설정할 selector 변수들.
* - containerSelector: string — 컨테이너를 찾을 CSS 선택자
* - containerCondition: () => boolean — 컨테이너 탐색 전 조건(예: pathname). 기본은 항상 true
* - commentRowSelector: string — 컨테이너 안 댓글 한 줄(행) 요소 선택자
* - commentTextSelector: string — 댓글 한 줄에서 텍스트를 꺼낼 하위 요소 선택자
* - checkboxSlotSelector: string — 댓글 한 줄에서 체크박스를 넣을 슬롯 요소 선택자(없으면 해당 행 사용)
* - commentInputSelector: string — 댓글 작성란 입력 요소 선택자(동기화된 타임라인 자동 기입 시 사용, 자식에서 설정)
* - commentInputCurrentTimeButtonSlotSelector: string — 댓글 작성란 입력 요소 안에 현재 시간 삽입 버튼을 넣을 슬롯 요소 선택자
* - commentInputTextareaSelector: string — 댓글 작성란 입력 요소 안에 텍스트 입력 요소 선택자
*/
constructor() {
super();
this._started = false;
/** 전달받은 타임라인 동기화 페이로드 (receive 시 저장) */
this._incomingTimelineSyncPayload = null;
/** 미리보기 창 뼈대 (receive 시 생성). listWrap은 뼈대의 내용 영역 참조용. */
this._timelinePreviewWrap = null;
this._timelinePreviewListWrap = null;
/** 채워넣은 행 데이터 (복사 버튼에서 사용) */
this._timelinePreviewRows = null;
/** 찾아둔 댓글 컨테이너. document에 연결되어 있으면 재탐색 생략 */
this._cachedCommentContainer = null;
/** 변환 체크가 된 댓글 한 줄 요소들. 체크 시 추가·해제 시 제거. */
this._selectedCommentRows = [];
/** 체크박스 스타일 객체. 자식 클래스에서 키 추가·값 수정 가능. (예: this.checkboxWrapStyle.right = '30px') */
this.checkboxWrapStyle = { position: 'absolute', top: '2px', right: '2px', zIndex: 1, fontSize: '13px', padding: '2px 6px', borderRadius: '4px', transition: 'background-color .15s ease' };
this.checkboxLabelStyle = { cursor: 'pointer', position: 'relative', display: 'inline-block' };
this.checkboxInputStyle = { position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'pointer' };
this.checkboxWrapCheckedStyle = { backgroundColor: '#a8d8ea', color: '#1a1a1a' };
this.checkboxWrapUncheckedStyle = { backgroundColor: 'rgba(0,0,0,0.06)', color: '#888' };
const GITHUB_RAW_URL = "https://raw.githubusercontent.com/AINukeHere/VOD-Master/main";
/** 현재 시간 삽입 버튼 스타일. backgroundImage는 런타임 URL 사용 */
this.insertCurrentTimeButtonStyle = {
backgroundImage:
window.VODSync?.IS_TAMPER_MONKEY_SCRIPT !== true
? `url(${chrome.runtime.getURL('res/img/AddCurrentTime.png')})`
: `url(${GITHUB_RAW_URL}/res/img/AddCurrentTime.png)`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
width: '32px',
height: '32px',
verticalAlign: 'middle',
borderRadius: '8px'
};
this.insertCurrentTimeButtonHoverStyle = { backgroundColor: 'rgb(232,232,232)' };
window.VODSync = window.VODSync || {};
window.VODSync.timelineCommentProcessor = this;
this.startWatching();
}
/**
* 타임라인 댓글 감시 시작. 주기적으로 컨테이너를 찾고, 있으면 해당 컨테이너에서 댓글을 찾아 체크박스를 붙인다.
* 수신 페이로드가 있으면 미리보기 목록 영역에 내용 채움.
*/
startWatching() {
if (this._started) return;
this._started = true;
setInterval(() => {
// 댓글 컨테이너 찾기
let container = this._cachedCommentContainer;
if (!container || !container.isConnected) {
container = this._getContainer();
this._cachedCommentContainer = container;
}
// 타임라인 댓글에 동기화시 변환 버튼 추가하기
this.scanAndAttachCheckboxes(container);
// 타임라인 댓글의 더보기를 눌렀을 때 편집 버튼 추가하기
this._injectEditButtonIntoMoreLayers(container);
// 댓글 작성 입력창에 타임라인 입력 버튼 추가하기
this._injectTinelineInsertButton(container);
// 수신 페이로드가 있으면 미리보기 목록 영역에 내용 채움
if (this._incomingTimelineSyncPayload)
this.fillTimelinePreviewContent(this._incomingTimelineSyncPayload);
}, 500);
}
// 댓글 컨테이너에서 댓글들을 찾아, 타임라인 댓글이면 변환 체크박스를 추가한다.
scanAndAttachCheckboxes(container) {
if (!container) return;
const comments = this._getComments(container);
for (const comment of comments) {
if (comment.querySelector(`.${this.constructor.CHECKBOX_CLASS}`)) continue;
const text = this._extractTextContent(comment);
const sec = this.parsePlaybackSecondsFromText(text);
if (sec == null) continue;
this.appendSyncCheckboxToRow(comment);
}
}
/** selector로 컨테이너 안 댓글 행 목록 반환 */
_getComments(container) {
if (!container) return [];
const sel = this.commentRowSelector;
if (!sel) return [];
return Array.from(container.querySelectorAll(sel));
}
/** selector로 댓글 한 줄에서 표시용 텍스트 추출 */
_extractTextContent(rowEl) {
const sel = this.commentTextSelector;
if (!sel) return rowEl?.textContent || '';
const el = rowEl.querySelector(sel);
return (el ? el.textContent : rowEl.textContent) || '';
}
/** style 객체를 요소에 적용. camelCase 키를 element.style에 그대로 대입. */
_applyStyle(el, styleObj) {
if (!styleObj) return;
for (const [k, v] of Object.entries(styleObj)) {
if (v != null && v !== '') el.style[k] = v;
}
}
/** selector로 댓글 컨테이너 반환 (containerCondition 적용 후 containerSelector로 querySelector) */
_getContainer() {
if (this.containerCondition && !this.containerCondition()) return null;
const sel = this.containerSelector;
return sel ? document.querySelector(sel) || null : null;
}
/** HH:MM:SS / MM:SS 패턴으로 재생 시각(초) 파싱. 서브클래스에서 오버라이드 가능. */
parsePlaybackSecondsFromText(text) {
if (!text || typeof text !== 'string') return null;
const t = text.trim();
const withSec = t.match(/(?:^|[\s\[(])(\d{1,2}):(\d{2}):(\d{2})(?:\s|]|\)|$)/);
if (withSec) return parseInt(withSec[1], 10) * 3600 + parseInt(withSec[2], 10) * 60 + parseInt(withSec[3], 10);
const minSec = t.match(/(?:^|[\s\[(])(\d{1,2}):(\d{2})(?:\s|]|\)|$)(?!\d)/);
if (minSec) return parseInt(minSec[1], 10) * 60 + parseInt(minSec[2], 10);
return null;
}
// 특정 댓글 한 줄 요소에 변환 체크박스를 추가. 체크/해제 시 _selectedCommentRows에 반영·배경색 시각화.
// '타임라인 댓글 편집하기' 버튼은 댓글 더보기 레이어(_moreDot_layer) 안에 주기적으로 주입됨.
appendSyncCheckboxToRow(rowEl) {
const slot = this._getCheckboxInsertSlot(rowEl);
if (!slot) return false;
const toggleWrap = document.createElement('span');
toggleWrap.className = this.constructor.CHECKBOX_WRAP_CLASS;
this._applyStyle(toggleWrap, this.checkboxWrapStyle);
const label = document.createElement('label');
label.title = this.constructor.LABEL_SYNC_TOOLTIP;
this._applyStyle(label, this.checkboxLabelStyle);
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = this.constructor.CHECKBOX_CLASS;
this._applyStyle(cb, this.checkboxInputStyle);
const updateWrapStyle = () => {
this._applyStyle(toggleWrap, cb.checked ? this.checkboxWrapCheckedStyle : this.checkboxWrapUncheckedStyle);
};
cb.addEventListener('change', () => {
updateWrapStyle();
if (cb.checked) {
if (!this._selectedCommentRows.includes(rowEl)) this._selectedCommentRows.push(rowEl);
} else {
this._selectedCommentRows = this._selectedCommentRows.filter(r => r !== rowEl);
}
});
updateWrapStyle();
label.appendChild(cb);
label.appendChild(document.createTextNode(this.constructor.LABEL_SYNC_CHECKBOX));
toggleWrap.appendChild(label);
const pos = window.getComputedStyle(slot).position;
if (!pos || pos === 'static') slot.style.position = 'relative';
slot.appendChild(toggleWrap);
return true;
}
// 댓글 더보기 레이어(._moreDot_layer)가 보일 때, 타임라인 댓글인 경우에만 편집 버튼을 넣음. SOOP 전용 등은 `_injectClipImportButtonIntoMoreLayer` 오버라이드.
_injectEditButtonIntoMoreLayers(container) {
if (!container?.isConnected) return;
const layers = container.querySelectorAll('._moreDot_layer');
for (const layer of layers) {
const rowEl = layer.closest(this.commentRowSelector);
if (!rowEl || !rowEl.querySelector(`.${this.constructor.CHECKBOX_CLASS}`)) continue;
const closeMore = () => {
if (layer.parentNode?.parentNode?.childNodes[0]) layer.parentNode.parentNode.childNodes[0].click();
};
if (!layer.querySelector(`.${this.constructor.EDIT_IN_MORE_CLASS}`)) {
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = this.constructor.EDIT_IN_MORE_CLASS;
editBtn.textContent = this.constructor.BTN_EDIT_IN_MORE;
editBtn.title = this.constructor.BTN_EDIT_IN_MORE_TOOLTIP;
editBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.openPreviewWithCurrentPageTimelineComments(rowEl);
closeMore();
});
layer.appendChild(editBtn);
}
this._injectClipImportButtonIntoMoreLayer(layer, rowEl, closeMore);
}
}
/** 플랫폼별: 더보기 레이어에 편집 구간 가져오기 등 추가 버튼 (기본 없음). */
_injectClipImportButtonIntoMoreLayer(layer, rowEl, closeMore) {}
/** selector로 체크박스를 넣을 슬롯 반환 (없으면 rowEl) */
_getCheckboxInsertSlot(rowEl) {
const sel = this.checkboxSlotSelector;
if (!sel) return rowEl;
return rowEl.querySelector(sel) || rowEl;
}
/** VOD linker가 호출. 변환 체크된 댓글 요소들을 모아 가공한 페이로드 반환. (storage/동기화와 동일한 형식: (string|number)[]) */
getTimelineSyncPayload() {
// _selectedCommentRows에서 연결된(실제 DOM에 남아있는) row만 추림
this._selectedCommentRows = this._selectedCommentRows.filter(row => row.isConnected);
return this._buildPayloadFromComments(this._selectedCommentRows);
}
/**
* 댓글 행 목록으로부터 storage/동기화와 동일한 페이로드 형식 생성.
* 미리보기(편집하기)와 변환 결과 모두 이 형식으로 fillTimelinePreviewContent에 넘긴다.
* @param {HTMLElement[]} rowEls 체크박스가 붙은 댓글 행 요소 배열
* @returns {(string|number)[]}
*/
_buildPayloadFromComments(rowEls) {
if (!Array.isArray(rowEls)) return [];
const segs = this.buildSegmentsFromComments(rowEls);
if (!Array.isArray(segs)) return [];
return segs;
}
/**
* 한 개 이상의 댓글에서 미리보기용 세그먼트 생성. 파생 클래스에서 오버라이드.
* @param {HTMLElement[]} commentEls 댓글 요소 배열
* @returns {(string|number)[]}
*/
buildSegmentsFromComments(commentEls) {
throw this.error('buildSegmentsFromComments is not implemented');
}
/**
* 미리보기 창을 열고 행 데이터로 채움. 변환 결과·현재 페이지 수집 모두 이 진입점 사용.
* @param {Array<Array<{type:'string',value:string}|{type:'timeline',playbackSec:number|null}>>} rows
*/
openTimelinePreview(rows) {
if (!Array.isArray(rows) || rows.length === 0) return;
if (!this._timelinePreviewWrap?.isConnected) {
this._createTimelinePreviewSkeleton();
}
this._incomingTimelineSyncPayload = null;
this._timelinePreviewRows = rows;
this._renderPreviewRows(rows);
}
/**
* 타임라인 편집하기 버튼이 클릭되면 이 함수가 호출됨.
* 해당 댓글 내용을 미리보기에 채우고 미리보기 창을 엽니다.
* @param {HTMLElement} commentEl 편집하기 버튼이 속한 댓글 요소
*/
openPreviewWithCurrentPageTimelineComments(commentEl) {
if (!commentEl?.isConnected) return;
const payload = this._buildPayloadFromComments([commentEl]);
if (payload.length === 0) return;
this.fillTimelinePreviewContent(payload);
}
/**
* 타임라인 동기화 페이로드를 전달받음. 뼈대가 없으면 미리보기 창 뼈대만 생성하고, 내용 채움은 인터벌에서 주기적으로 시도.
* @param {(string|number)[]} payload
*/
receiveTimelineSyncPayload(payload) {
if (!Array.isArray(payload) || payload.length === 0) return;
this._incomingTimelineSyncPayload = payload;
if (!this._timelinePreviewWrap?.isConnected) {
this._createTimelinePreviewSkeleton();
}
}
// 미리보기 창 뼈대만 생성 (헤더·빈 목록 영역·푸터). 내용은 fillTimelinePreviewContent()에서 채움.
_createTimelinePreviewSkeleton() {
const wrap = document.createElement('div');
wrap.className = 'vodSync-timeline-preview-wrap';
wrap.style.cssText = 'position:fixed;right:16px;bottom:16px;width:420px;max-width:90vw;max-height:80vh;z-index:99999;display:flex;flex-direction:column;box-shadow:0 4px 20px rgba(0,0,0,0.2);border-radius:8px;overflow:hidden;background:#fff;';
const panel = document.createElement('div');
panel.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0;';
const header = document.createElement('div');
header.style.cssText = 'padding:10px 12px;border-bottom:1px solid #eee;font-weight:bold;font-size:14px;flex-shrink:0;display:flex;align-items:center;justify-content:space-between;gap:8px;';
header.textContent = this.constructor.PANEL_HEADER;
const collapseBtn = document.createElement('button');
collapseBtn.type = 'button';
collapseBtn.textContent = this.constructor.BTN_COLLAPSE;
collapseBtn.style.cssText = 'padding:4px 10px;font-size:12px;cursor:pointer;';
const bodyArea = document.createElement('div');
bodyArea.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;';
const listWrap = document.createElement('div');
listWrap.style.cssText = 'overflow:auto;flex:1;min-height:120px;padding:8px;';
const footer = document.createElement('div');
footer.style.cssText = 'padding:10px 12px;border-top:1px solid #eee;display:flex;gap:8px;justify-content:flex-end;flex-shrink:0;';
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.textContent = this.constructor.BTN_COPY;
copyBtn.style.cssText = 'padding:6px 12px;cursor:pointer;background:#1a73e8;color:#fff;border:none;border-radius:4px;font-size:12px;';
copyBtn.addEventListener('click', () => {
const rows = this._timelinePreviewRows;
if (!rows || rows.length === 0) return;
const text = rows.map(rowFrags =>
rowFrags.map(f => f.type === 'string' ? f.value : (f.playbackSec != null ? this.formatPlaybackTimeAsComment(f.playbackSec).trim() : this.constructor.TIME_PLACEHOLDER + ' ')).join('')
).join('\n');
if (text) navigator.clipboard.writeText(text).then(() => { copyBtn.textContent = this.constructor.BTN_COPIED; setTimeout(() => { copyBtn.textContent = this.constructor.BTN_COPY; }, 1500); }).catch(() => { copyBtn.textContent = this.constructor.BTN_COPY_FAILED; });
});
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = this.constructor.BTN_CLOSE;
closeBtn.style.cssText = 'padding:6px 12px;cursor:pointer;background:#666;color:#fff;border:none;border-radius:4px;font-size:12px;';
closeBtn.addEventListener('click', () => {
wrap.remove();
this._timelinePreviewWrap = null;
this._timelinePreviewListWrap = null;
this._timelinePreviewRows = null;
});
collapseBtn.addEventListener('click', () => {
const collapsed = bodyArea.style.display === 'none';
bodyArea.style.display = collapsed ? 'flex' : 'none';
collapseBtn.textContent = collapsed ? this.constructor.BTN_COLLAPSE : this.constructor.BTN_EXPAND;
});
header.appendChild(collapseBtn);
footer.appendChild(copyBtn);
footer.appendChild(closeBtn);
bodyArea.appendChild(listWrap);
bodyArea.appendChild(footer);
panel.appendChild(header);
panel.appendChild(bodyArea);
wrap.appendChild(panel);
document.body.appendChild(wrap);
this._timelinePreviewWrap = wrap;
this._timelinePreviewListWrap = listWrap;
}
// 수신한 페이로드로부터 변환된 타임라인 댓글 미리보기 목록 영역에 내용 채움. 내부에서 openTimelinePreview(rows) 호출.
fillTimelinePreviewContent(payload) {
if (!Array.isArray(payload) || payload.length === 0) return;
const tsManager = window.VODSync?.tsManager;
if (!tsManager?.canConvertGlobalTSToPlaybackTime()) return;
const globalTSToPlaybackTime = tsManager.globalTSToPlaybackTime;
if (!globalTSToPlaybackTime) return;
// 페이로드 → 순서 유지 fragments (string | timeline), \n 기준으로 행 분리
const fragments = [];
for (const item of payload) {
const asGlobalMs = typeof item === 'number' && !isNaN(item)
? item
: (typeof item === 'string' && /^\d{10,15}$/.test(String(item).trim())
? parseInt(item, 10)
: NaN);
if (!isNaN(asGlobalMs)) {
const sec = globalTSToPlaybackTime.call(tsManager, asGlobalMs);
if (sec != null) {
fragments.push({ type: 'timeline', playbackSec: Math.max(0, Math.floor(sec)) });
} else {
fragments.push({ type: 'timeline', playbackSec: null });
}
} else if (typeof item === 'string') {
fragments.push({ type: 'string', value: item });
}
}
const rows = [];
let currentRow = [];
for (const frag of fragments) {
if (frag.type === 'string') {
const parts = frag.value.split('\n');
for (let i = 0; i < parts.length; i++) {
if (i > 0) {
rows.push(currentRow);
currentRow = [];
}
if (parts[i].length > 0) currentRow.push({ type: 'string', value: parts[i] });
}
} else {
currentRow.push(frag);
}
}
if (currentRow.length > 0) rows.push(currentRow);
if (rows.length === 0) return;
this.openTimelinePreview(rows);
}
/** 미리보기 목록 영역에 행 데이터를 DOM으로 채움. openTimelinePreview → fillTimelinePreviewContent / openPreviewWithCurrentPageTimelineComments 에서 사용. */
_renderPreviewRows(rows) {
if (!this._timelinePreviewListWrap?.isConnected || !Array.isArray(rows) || rows.length === 0) return;
const listWrap = this._timelinePreviewListWrap;
listWrap.textContent = '';
rows.forEach((rowFragments) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;padding:6px 8px;border-radius:4px;margin-bottom:4px;border:1px solid #eee;font-size:13px;';
for (const frag of rowFragments) {
if (frag.type === 'string') {
const textSpan = document.createElement('span');
textSpan.style.whiteSpace = 'pre-wrap';
textSpan.textContent = frag.value;
row.appendChild(textSpan);
} else {
if (frag.playbackSec == null) {
const placeholder = document.createElement('span');
placeholder.textContent = this.constructor.TIME_PLACEHOLDER;
placeholder.style.cssText = 'font-family:monospace;color:#999;';
// TODO: 치지직에서도 간단하게 element 구성만으로 이동이 가능하다면 굳이 이걸 타임라인부분에 이벤트리스너를 추가할 필요가 없음.
// timeEl.addEventListener('click', (e) => { e.stopPropagation(); if (moveToPlaybackTime) moveToPlaybackTime(frag.playbackSec, false); });
row.appendChild(placeholder);
} else {
const timeEl = this.createTimelineDisplayElement(frag.playbackSec);
const timeBtnStyle = 'min-width:24px;padding:4px 8px;font-size:12px;font-weight:600;cursor:pointer;border:1px solid #ccc;border-radius:4px;background:#f5f5f5;color:#333;line-height:1;';
const btnMinus = document.createElement('button');
btnMinus.type = 'button';
btnMinus.textContent = this.constructor.BTN_TIME_MINUS;
btnMinus.style.cssText = timeBtnStyle;
btnMinus.title = '쉬프트를 누른 상태로 클릭하면 10초씩 감소';
const btnPlus = document.createElement('button');
btnPlus.type = 'button';
btnPlus.textContent = this.constructor.BTN_TIME_PLUS;
btnPlus.style.cssText = timeBtnStyle;
btnPlus.title = '쉬프트를 누른 상태로 클릭하면 10초씩 증가';
const setTimeBtnHover = (btn, hover) => {
btn.style.background = hover ? '#e0e0e0' : '#f5f5f5';
btn.style.borderColor = hover ? '#999' : '#ccc';
};
btnMinus.addEventListener('mouseenter', () => setTimeBtnHover(btnMinus, true));
btnMinus.addEventListener('mouseleave', () => setTimeBtnHover(btnMinus, false));
btnPlus.addEventListener('mouseenter', () => setTimeBtnHover(btnPlus, true));
btnPlus.addEventListener('mouseleave', () => setTimeBtnHover(btnPlus, false));
btnMinus.addEventListener('click', (e) => {
e.stopPropagation();
const delta = e.shiftKey ? 10 : 1;
frag.playbackSec = Math.max(0, frag.playbackSec - delta);
if (timeEl._vodSyncUpdateTime) timeEl._vodSyncUpdateTime(frag.playbackSec);
else timeEl.textContent = this.getTimelineDisplayText(frag.playbackSec);
});
btnPlus.addEventListener('click', (e) => {
e.stopPropagation();
const delta = e.shiftKey ? 10 : 1;
frag.playbackSec += delta;
if (timeEl._vodSyncUpdateTime) timeEl._vodSyncUpdateTime(frag.playbackSec);
else timeEl.textContent = this.getTimelineDisplayText(frag.playbackSec);
});
row.appendChild(timeEl);
row.appendChild(btnMinus);
row.appendChild(btnPlus);
}
}
}
listWrap.appendChild(row);
});
}
// 재생 시각(초)을 댓글용 시간 문자열로 포맷. 자식 클래스에서 오버라이드 가능.
formatPlaybackTimeAsComment(playbackSec) {
if (typeof playbackSec !== 'number' || playbackSec < 0 || !isFinite(playbackSec)) return '';
const h = Math.floor(playbackSec / 3600);
const m = Math.floor((playbackSec % 3600) / 60);
const s = Math.floor(playbackSec % 60);
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')} `;
return `${m}:${String(s).padStart(2, '0')} `;
}
/**
* 미리보기 패널에서 타임라인 한 칸에 표시할 문자열 (H:MM:SS 또는 M:SS). 자식에서 오버라이드 가능.
* @param {number} playbackSec
* @returns {string}
*/
getTimelineDisplayText(playbackSec) {
if (typeof playbackSec !== 'number' || playbackSec < 0 || !isFinite(playbackSec)) return this.constructor.TIME_PLACEHOLDER;
const h = Math.floor(playbackSec / 3600);
const m = Math.floor((playbackSec % 3600) / 60);
const s = Math.floor(playbackSec % 60);
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
/**
* 미리보기/댓글에 넣을 타임라인 한 칸 DOM 요소 생성. 플랫폼별로 오버라이드.
* @param {number} playbackSec 재생 시각(초)
* @returns {HTMLElement}
*/
createTimelineDisplayElement(playbackSec) {
const span = document.createElement('span');
span.className = 'vodSync-timeline-preview-time';
span.textContent = this.getTimelineDisplayText(playbackSec);
span.style.cssText = 'font-family:monospace;font-size:13px;cursor:pointer;text-decoration:underline;';
span._vodSyncUpdateTime = (sec) => { span.textContent = this.getTimelineDisplayText(sec); };
return span;
}
_injectTinelineInsertButton() {
const inputList = document.querySelectorAll(this.commentInputSelector);
if (!inputList || inputList.length === 0) return;
for (const input of inputList) {
const existingButton = input.querySelector(`.${this.constructor.BTN_INSERT_CURRENT_TIME_CLASS}`);
if (existingButton) continue;
const buttonParent = input.querySelector(this.commentInputCurrentTimeButtonSlotSelector);
if (!buttonParent) continue;
const textarea = input.querySelector(this.commentInputTextareaSelector);
if (!textarea) continue;
const button = document.createElement('button');
button.type = 'button';
button.className = this.constructor.BTN_INSERT_CURRENT_TIME_CLASS;
this._applyStyle(button, this.insertCurrentTimeButtonStyle);
button.addEventListener('mouseenter', () => this._applyStyle(button, this.insertCurrentTimeButtonHoverStyle));
button.addEventListener('mouseleave', () => { button.style.backgroundColor = ''; });
button.title = this.constructor.BTN_INSERT_CURRENT_TIME_LABEL;
const span = document.createElement('span');
span.textContent = this.constructor.BTN_INSERT_CURRENT_TIME_LABEL;
span.style.font = '0/0 a';
button.appendChild(span);
const doInsert = () => {
const tsManager = window.VODSync?.tsManager;
if (!tsManager?.getCurPlaybackTime()) return;
const currentTime = tsManager.getCurPlaybackTime();
const currentTimeText = this.formatPlaybackTimeAsComment(currentTime);
const selection = window.getSelection();
const range = selection.rangeCount ? selection.getRangeAt(0) : null;
if (range && textarea.contains(range.startContainer))
this.insertTimeTextAtRange(textarea, range, currentTimeText);
else
this.insertTimeTextAtEnd(textarea, currentTimeText);
};
button.addEventListener('click', doInsert);
input.addEventListener('keydown', (e) => {
if (!e.altKey || e.key !== 't') return;
if (textarea !== document.activeElement && !textarea.contains(document.activeElement)) return;
e.preventDefault();
doInsert();
});
buttonParent.appendChild(button);
}
}
/** Range 위치에 현재 시간 텍스트를 삽입하고, 캐럿을 삽입된 텍스트 끝으로 둔다 */
insertTimeTextAtRange(textarea, range, currentTimeText) {
if (!textarea.contains(range.startContainer) || !textarea.contains(range.endContainer))
return;
range.deleteContents();
const newTextNode = document.createTextNode(currentTimeText);
range.insertNode(newTextNode);
this._setCaretAfterNodeAndFocus(textarea, newTextNode);
}
/** textarea 끝에 현재 시간 텍스트를 붙이고, 캐럿을 삽입된 텍스트 끝으로 둔다 */
insertTimeTextAtEnd(textarea, currentTimeText) {
const newTextNode = document.createTextNode(currentTimeText);
textarea.appendChild(newTextNode);
this._setCaretAfterNodeAndFocus(textarea, newTextNode);
}
/** 텍스트 노드 끝에 캐럿을 두고 textarea에 포커스한다. */
_setCaretAfterNodeAndFocus(textarea, node) {
const range = document.createRange();
range.setStart(node, node.length);
range.setEnd(node, node.length);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
setTimeout(() => textarea.focus(), 0);
}
}
class SoopTimelineCommentProcessor extends TimelineCommentProcessorBase {
/** 더보기 레이어 안 편집 구간 가져오기 버튼(SOOP 전용) */
static CLIP_IMPORT_IN_MORE_CLASS = 'vodSync-timeline-clip-import-in-more';
static BTN_CLIP_IMPORT_IN_MORE = '편집 구간으로 가져오기';
static BTN_CLIP_IMPORT_IN_MORE_TOOLTIP = '본문에서 시간 ~ 시간 구간을 찾아 VOD 편집 구간으로 추가합니다';
constructor() {
super();
// Selector override
this.containerSelector = '#commentHighlight';
this.commentRowSelector = 'li';
this.commentTextSelector = '.cmmt-txt';
this.checkboxSlotSelector = '.cmmt-header';
this.commentInputSelector = 'section.cmmt_inp'; // 댓글 작성란 입력 요소
this.commentInputCurrentTimeButtonSlotSelector = 'div.grid-start'; // 댓글 작성란 입력 요소 내부의 현재 시간 삽입 버튼 추가 슬롯
this.commentInputTextareaSelector = 'div.write-inp'; // 댓글 작성란 입력 요소 내부의 텍스트 입력 요소
// Style override
this.checkboxWrapStyle.right = '30px';
}
/**
* 한 개 이상의 댓글에서 미리보기용 세그먼트 생성.
* @param {HTMLElement[]} rowEls 댓글 행 요소 배열
* @returns {(string|number)[]}
*/
buildSegmentsFromComments(commentEls) {
const tsManager = window.VODSync?.tsManager;
const result = [];
for (const commentEl of commentEls) {
const cmmtTxt = commentEl?.querySelector('.cmmt-txt');
if (!cmmtTxt) continue;
const root = cmmtTxt.querySelector('p') || cmmtTxt;
const nodes = root.childNodes;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent;
if (t) result.push(t);
continue;
}
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.classList?.contains('best')) continue;
if (node.tagName === 'BR') { result.push('\n'); continue; }
if (node.classList?.contains('time_link') && node.hasAttribute('data-time')) {
const sec = parseInt(node.getAttribute('data-time'), 10);
if (!isNaN(sec) && tsManager?.playbackTimeToGlobalTS) {
const globalDate = tsManager.playbackTimeToGlobalTS(sec);
if (globalDate instanceof Date && !isNaN(globalDate.getTime())) {
result.push(globalDate.getTime());
}
}
continue;
}
const t = node.textContent?.trim();
if (t) result.push(t);
}
}
return result;
}
/** Soop 댓글 타임라인 스타일: <a class="time_link">[ <strong class="time_link">HH:MM:SS</strong> ]</a> */
createTimelineDisplayElement(playbackSec) {
const sec = Math.floor(playbackSec);
const a = document.createElement('a');
a.setAttribute('data-time', String(sec));
a.className = 'time_link';
a.style.cursor = 'pointer';
a.appendChild(document.createTextNode('[ '));
const strong = document.createElement('strong');
strong.className = 'time_link';
strong.style.color = '#0182ff';
strong.setAttribute('data-time', String(sec));
strong.textContent = this.getTimelineDisplayText(playbackSec);
a.appendChild(strong);
a.appendChild(document.createTextNode(' ]'));
const self = this;
a._vodSyncUpdateTime = (s) => {
const n = Math.floor(s);
a.setAttribute('data-time', String(n));
strong.setAttribute('data-time', String(n));
strong.textContent = self.getTimelineDisplayText(s);
};
return a;
}
/** 단일 시각 토큰(예: 1:49:49)을 초로 변환. 편집 구간 가져오기 줄 파싱용. */
parseHmsTokenToSeconds(token) {
if (token == null || typeof token !== 'string') return null;
return this.parsePlaybackSecondsFromText(token.trim());
}
/**
* 한 줄에서 `앞글자 [ 01:02:03 ] ~ [ 4:05 ] 뒤글자` 또는 `1:02:03 ~ 4:05` 패턴을 찾는다(SOOP time_link 텍스트).
* 앞·뒤 trim 후 공백으로 이은 문자열이 편집 구간 이름(둘 다 비면 이름 생략).
* @returns {{ begin: number, end: number, name?: string }|null}
*/
parseCommentLineForClipRange(line) {
if (!line || typeof line !== 'string') return null;
const hms = String.raw`\d{1,2}:\d{2}(?::\d{2})?`;
const timeTok = String.raw`(?:\[\s*)?(${hms})(?:\s*\])?`;
const m = line.match(new RegExp(String.raw`^([\s\S]*?)${timeTok}\s*~\s*${timeTok}\s*([\s\S]*)$`));
if (!m) return null;
const begin = this.parseHmsTokenToSeconds(m[2]);
const end = this.parseHmsTokenToSeconds(m[3]);
if (begin == null || end == null) return null;
const left = m[1].trim();
const right = m[4].trim();
const name = [left, right].filter(Boolean).join(' ');
return name ? { begin, end, name } : { begin, end };
}
/** 더보기 메뉴: 댓글에서 구간을 파싱해 편집 VOD 편집 구간으로 넘긴다. */
importClipsFromCommentRow(rowEl) {
const lines = this.getCommentLinesForClipImport(rowEl);
const items = [];
for (const line of lines) {
const one = this.parseCommentLineForClipRange(line);
if (one) items.push(one);
}
if (items.length === 0) {
window.alert(
'가져올 구간이 없습니다. "128강 1:49:49 ~ 1:54:48" 형식이 있는지 확인하세요.'
);
return;
}
const veditor = window.VODSync?.soopVeditorReplacement;
if (!veditor || typeof veditor.importClipsFromParsedRanges !== 'function') {
window.alert('VOD 편집 패널을 아직 불러오지 못했습니다. 페이지를 새로고침한 뒤 다시 시도하세요.');
return;
}
veditor.importClipsFromParsedRanges(items);
}
_injectClipImportButtonIntoMoreLayer(layer, rowEl, closeMore) {
if (!layer.querySelector(`.${this.constructor.CLIP_IMPORT_IN_MORE_CLASS}`)) {
const clipBtn = document.createElement('button');
clipBtn.type = 'button';
clipBtn.className = this.constructor.CLIP_IMPORT_IN_MORE_CLASS;
clipBtn.textContent = this.constructor.BTN_CLIP_IMPORT_IN_MORE;
clipBtn.title = this.constructor.BTN_CLIP_IMPORT_IN_MORE_TOOLTIP;
clipBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.importClipsFromCommentRow(rowEl);
closeMore();
});
layer.appendChild(clipBtn);
}
}
/** BR·블록 경계마다 줄을 나눠 `시간 ~ 시간` 줄 파싱에 쓴다. */
getCommentLinesForClipImport(rowEl) {
const cmmtTxt = rowEl?.querySelector('.cmmt-txt');
if (!cmmtTxt) {
const raw = this._extractTextContent(rowEl);
return raw
.split(/\r?\n/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
const root = cmmtTxt.querySelector('p') || cmmtTxt;
const lines = [];
let buf = '';
const flush = () => {
const t = buf.trim();
if (t) lines.push(t);
buf = '';
};
for (let i = 0; i < root.childNodes.length; i++) {
const node = root.childNodes[i];
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent;
if (t) buf += t;
continue;
}
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.classList?.contains('best')) continue;
if (node.tagName === 'BR') {
flush();
continue;
}
const t = node.textContent;
if (t) buf += t;
}
flush();
return lines;
}
}
class SoopPrevChatViewer extends IVodSync {
constructor() {
super();
this.restoreButton = null;
this.settingsButton = null;
this.buttonContainer = null;
this.chatMemo = null; // chatMemo 참조 저장 (복구된 채팅 추가용)
this.boxVstart = null; // boxVstart 참조 저장 (채팅 초기화 감지용)
this.settingsPopup = null;
this.isRestoring = false;
this.checkInterval = null;
// 복원 구간: startTime/endTime (playbackTime 기준)
this._restoreTimeRange = null;
this.vodInfo = null; // VOD 정보 캐시
this.signatureEmoticon = null; // 시그니처 이모티콘 데이터 캐시
this.defaultEmoticon = null; // 기본 이모티콘 데이터 캐시
this.emoticonReplaceMap = new Map(); // 이모티콘 ID -> 이미지 HTML 매핑
this.cachedChatData = []; // 캐시된 채팅 데이터 [{startTime, endTime, messages}, ...]
this.restoreInterval = 30; // 복원 구간 단위 (초)
this.excludeEmoticonOnlyChat = false; // 이모티콘만으로 이루어진 채팅 복원 제외 여부
this.initialRestoreEndTime = null; // statVBox 재생성 시점의 복구 끝지점 (playbackTime, 초 단위)
this.sharedTooltip = null; // 재사용할 공통 툴팁 요소
this._tooltipHideTimeout = null; // 툴팁 mouseleave 시 지연 숨김용
this._soopUrls = window.VODSync?.SoopUrls || {};
this.log('loaded');
this.loadRestoreInterval();
this.init();
}
// restoreTimeRange getter/setter (setter에서 자동으로 버튼 텍스트 업데이트)
get nextRestorePlan() {return this._restoreTimeRange;}
set nextRestorePlan(value) {
this._restoreTimeRange = value;
this.updateButtonText();
}
// 설정에서 복원 구간 불러오기
async loadRestoreInterval() {
// 크롬 확장 프로그램 환경에서만 설정 로드 (탬퍼몽키가 아닌 경우)
if (window.VODSync?.IS_TAMPER_MONKEY_SCRIPT === true) {
return;
}
try {
const response = await chrome.runtime.sendMessage({ action: 'getAllSettings' });
if (response && response.success && response.settings) {
const interval = response.settings.soopRestoreInterval;
if (interval !== undefined) {
this.restoreInterval = interval;
this.log(`복원 구간 설정 로드: ${interval}초`);
}
if (response.settings.soopExcludeEmoticonOnlyChat !== undefined) {
this.excludeEmoticonOnlyChat = response.settings.soopExcludeEmoticonOnlyChat;
this.log('이모티콘만 복원 제외 설정 로드:', this.excludeEmoticonOnlyChat);
}
}
} catch (error) {
this.log('복원 구간 설정 로드 실패:', error);
}
}
// 복원 구간 설정 저장
async saveRestoreInterval() {
// 크롬 확장 프로그램 환경에서만 설정 저장 (탬퍼몽키가 아닌 경우)
if (window.VODSync?.IS_TAMPER_MONKEY_SCRIPT === true) {
return;
}
try {
const response = await chrome.runtime.sendMessage({
action: 'saveSettings',
settings: {
soopRestoreInterval: this.restoreInterval,
soopExcludeEmoticonOnlyChat: this.excludeEmoticonOnlyChat
}
});
if (response && response.success) {
this.log(`복원 구간 설정 저장: ${this.restoreInterval}초, 이모티콘만 제외: ${this.excludeEmoticonOnlyChat}`);
}
} catch (error) {
this.log('복원 구간 설정 저장 실패:', error);
}
}
init() {
// 공통 툴팁 요소 생성
this.sharedTooltip = document.createElement('div');
this.sharedTooltip.className = 'vodsync-chat-tooltip';
this.sharedTooltip.style.cssText = `
position: fixed;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.85);
color: white;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
z-index: 10000;
`;
// 툴팁 클릭 시 해당 시점으로 이동 (툴팁만 클릭 가능하도록 여기서만 처리)
this.sharedTooltip.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const sec = this.sharedTooltip.dataset.playbackTimeSeconds;
if (sec === undefined || sec === '') return;
const playbackTimeSeconds = parseInt(sec, 10);
const tsManager = window.VODSync?.tsManager;
if (tsManager && typeof tsManager.moveToPlaybackTime === 'function') {
tsManager.moveToPlaybackTime(playbackTimeSeconds, true);
}
this.sharedTooltip.style.opacity = '0';
this.sharedTooltip.style.pointerEvents = 'none';
});
this.sharedTooltip.addEventListener('mouseenter', () => {
if (this._tooltipHideTimeout) {
clearTimeout(this._tooltipHideTimeout);
this._tooltipHideTimeout = null;
}
});
this.sharedTooltip.addEventListener('mouseleave', () => {
this._tooltipHideTimeout = setTimeout(() => {
this._tooltipHideTimeout = null;
if (this.sharedTooltip) {
this.sharedTooltip.style.opacity = '0';
this.sharedTooltip.style.pointerEvents = 'none';
}
}, 100);
});
document.body.appendChild(this.sharedTooltip);
setTimeout(() => {
this.checkInterval = setInterval(() => this.monitoringChatBoxVstartChange(), 500);
}, 1000);
}
// boxVstart 변화 감지 및 채팅 초기화 처리
monitoringChatBoxVstartChange() {
if (this.boxVstart && this.boxVstart.isConnected) return;
if (this.buttonContainer){
this.buttonContainer.remove();
this.buttonContainer = null;
this.restoreButton = null;
this.settingsButton = null;
this.chatMemo = null;
this.boxVstart = null;
this.initialRestoreEndTime = null; // statVBox 재생성 시 초기화
}
// ~ 이후에 저장된 채팅입니다. 메시지 찾기
const boxVstart = document.getElementById('boxVstart');
if (!boxVstart) return;
const chatMemo = boxVstart.parentElement;
if (!chatMemo) return;
const chatArea = document.getElementById('chatArea');
if (!chatArea) return;
const video = document.querySelector('#video');
if (!video || !video.src || video.readyState < 2) return;
const tsManager = window.VODSync?.tsManager;
if (!tsManager) {
this.warn('SoopTimestampManager를 찾을 수 없습니다.');
return;
}
const currentPlaybackTime = tsManager.getCurPlaybackTime();
if (currentPlaybackTime === null) {
this.warn('재생 시간을 가져올 수 없습니다.');
return;
}
const endTime = currentPlaybackTime;
const startTime = Math.max(0, currentPlaybackTime - this.restoreInterval);
// statVBox 재생성 시점의 복구 끝지점 저장 (처음 세팅되는 시점)
if (this.initialRestoreEndTime === null) {
this.initialRestoreEndTime = endTime;
}
this.nextRestorePlan = {
startTime,
endTime
};
this.addRestoreButton(chatArea, chatMemo);
this.log(`채팅 초기화 감지 및 복원 구간 설정: ${this.formatTime(startTime)} ~ ${this.formatTime(endTime)}`);
}
// 복원 버튼 추가
addRestoreButton(chatArea, chatMemo) {
// 버튼 컨테이너 생성
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = 'display: flex; align-items: center; gap: 5px; margin: 10px; height:35px;';
buttonContainer.setAttribute('data-vodsync-restore-container', 'true');
const button = document.createElement('button');
button.setAttribute('data-vodsync-restore', 'true');
button.style.cssText = `
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
width: 100%;
height: 35px;
`;
button.addEventListener('click', () => this.restorePreviousChats());
button.addEventListener('mouseenter', () => {
if (!button.disabled) {
button.style.backgroundColor = '#45a049';
}
});
button.addEventListener('mouseleave', () => {
if (!button.disabled) {
button.style.backgroundColor = '#4CAF50';
}
});
// 설정 버튼 생성
const settingsBtn = document.createElement('button');
settingsBtn.setAttribute('data-vodsync-settings', 'true');
settingsBtn.innerHTML = '⚙️';
settingsBtn.style.cssText = `
padding: 8px 12px;
background-color: #666;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
max-height: 35px;
`;
settingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showSettingsPopup();
});
settingsBtn.addEventListener('mouseenter', () => settingsBtn.style.backgroundColor = '#555');
settingsBtn.addEventListener('mouseleave', () => settingsBtn.style.backgroundColor = '#666');
buttonContainer.appendChild(button);
buttonContainer.appendChild(settingsBtn);
// chatArea의 첫 번째 요소 앞에 버튼 컨테이너 추가
chatArea.insertBefore(buttonContainer, chatArea.firstChild);
this.buttonContainer = buttonContainer;
this.restoreButton = button;
this.settingsButton = settingsBtn;
this.chatMemo = chatMemo;
this.boxVstart = boxVstart;
this.updateButtonText();
}
// 채팅 복원 실행
async restorePreviousChats() {
if (this.isRestoring || !this.restoreButton || !this.nextRestorePlan) return;
this.isRestoring = true;
this.updateButtonText();
try {
const { startTime, endTime } = this.nextRestorePlan;
const videoId = this.getVideoId();
if (!videoId) throw new Error('VOD ID를 가져올 수 없습니다.');
const soopAPI = window.VODSync?.soopAPI;
if (!soopAPI) throw new Error('SoopAPI를 찾을 수 없습니다.');
// vodInfo 먼저 요청해서 chat_duration 확인
if (!this.vodInfo) {
this.vodInfo = await soopAPI.GetSoopVodInfo(videoId);
this.signatureEmoticon = await soopAPI.GetSignitureEmoticon(this.vodInfo?.data?.bj_id);
this.defaultEmoticon = await soopAPI.GetEmoticon();
this.buildEmoticonReplaceMap();
this.log(`시그니처 이모티콘 로드 완료: ${this.signatureEmoticon}`);
this.log(`기본 이모티콘 로드 완료: ${this.defaultEmoticon}`);
}
const chatDuration = this.vodInfo?.data?.chat_duration || 300; // 기본값 300초
// 캐시에서 해당 구간 찾기
let messages = this.getCachedChatData(startTime, endTime);
// 캐시에 없으면 요청해서 캐시에 저장
if (messages === null) {
const fetchStartTime = Math.max(0, endTime - chatDuration);
messages = await this.fetchAndCacheChatData(videoId, fetchStartTime, endTime, chatDuration);
}
// 실제 복원 구간만 필터링
let filteredMessages = messages.filter(msg =>
msg.timestamp >= startTime * 1000 && msg.timestamp <= endTime * 1000
);
let excludedCount = 0;
if (this.excludeEmoticonOnlyChat) {
const included = [];
for (const msg of filteredMessages) {
if (this.isEmoticonOnlyMessage(msg)) {
excludedCount++;
} else {
included.push(msg);
}
}
filteredMessages = included;
}
let restoredCount = 0;
if (filteredMessages.length > 0) {
const chatElements = filteredMessages.map(msg => this.createChatElement(msg)).filter(el => el !== null);
restoredCount = chatElements.length;
this.insertChatsBelowButton(chatElements);
this.log(`${restoredCount}개 채팅 복원 완료` + (excludedCount > 0 ? ` (${excludedCount}개 제외)` : ''));
} else {
this.log('복원할 채팅이 없습니다.');
}
// 다음 복원 구간 계산 (더 이전 restoreInterval만큼)
const nextStart = Math.max(0, startTime - this.restoreInterval);
const nextEnd = startTime;
const suffix = excludedCount > 0
? ` - ${restoredCount}개, ${excludedCount} 제외`
: ` - ${restoredCount}개`;
this._restoreTimeRange = { startTime: nextStart, endTime: nextEnd };
this.isRestoring = false;
this.updateButtonText(suffix);
} catch (error) {
this.isRestoring = false;
this.error('채팅 복원 오류:', error);
if (this.restoreButton) {
this.updateButtonText(' - 복원 실패, 다시 시도');
}
}
}
// 캐시에서 해당 구간의 채팅 데이터 찾기
getCachedChatData(startTime, endTime) {
const startTimeMs = startTime * 1000;
const endTimeMs = endTime * 1000;
for (const cache of this.cachedChatData) {
const cacheStartMs = cache.startTime * 1000;
const cacheEndMs = cache.endTime * 1000;
// 요청 구간이 캐시 구간에 완전히 포함되는지 확인
if (startTimeMs >= cacheStartMs && endTimeMs <= cacheEndMs) {
return cache.messages;
}
}
return null; // 캐시에 없음
}
// 채팅 데이터를 가져와서 캐시에 저장
async fetchAndCacheChatData(videoId, fetchStartTime, endTime, chatDuration) {
const soopAPI = window.VODSync?.soopAPI;
if (!soopAPI) throw new Error('SoopAPI를 찾을 수 없습니다.');
this.log(`채팅 로그 요청: ${fetchStartTime}초 ~ ${endTime}초 (chat_duration: ${chatDuration}초)`);
const chatLogXml = await soopAPI.GetChatLog(videoId, fetchStartTime, endTime);
if (!chatLogXml) {
this.warn('채팅 로그를 가져올 수 없습니다.');
return [];
}
// 필터링 없이 모든 메시지 파싱 (캐시용)
const messages = this.parseChatLogXmlRaw(chatLogXml);
// 캐시에 저장
this.cachedChatData.push({
startTime: fetchStartTime,
endTime: endTime,
messages: messages
});
this.log(`캐시 저장: ${fetchStartTime}초 ~ ${endTime}초 (${messages.length}개 메시지)`);
return messages;
}
// XML 파싱하여 메시지 데이터 반환 (필터링 없이 모든 메시지)
parseChatLogXmlRaw(xmlText) {
const messages = [];
try {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "application/xml");
const parserError = xmlDoc.querySelector("parsererror");
if (parserError) {
this.error("XML 파싱 오류:", parserError.textContent || parserError.innerText || '');
return [];
}
Array.from(xmlDoc.querySelectorAll("root > chat, root > ogq")).forEach((chat) => {
const msg = (chat.querySelector('m')?.textContent || '').trim();
const timestampStr = (chat.querySelector('t')?.textContent || '').trim();
const isOgq = chat.tagName.toLowerCase() === 'ogq';
let pValue, p2Value;
if (isOgq) {
const sfValue = (chat.querySelector('sf')?.textContent || '').trim();
[pValue, p2Value] = sfValue.split('|').map(v => v.trim());
} else {
pValue = (chat.querySelector('p')?.textContent || '').trim();
p2Value = (chat.querySelector('p2')?.textContent || '').trim();
}
const nicknameColor = (chat.querySelector('nf')?.textContent || '').trim();
const subscriptionMonths = (chat.querySelector('acfw')?.textContent || '').trim();
const ogqGid = isOgq ? (chat.querySelector('gid')?.textContent || '').trim() : null;
const ogqSid = isOgq ? (chat.querySelector('sid')?.textContent || '').trim() : null;
const ogqVersion = isOgq ? (chat.querySelector('v')?.textContent || '').trim() : null;
const ogqAnm = isOgq ? (chat.querySelector('anm')?.textContent || '').trim() : null;
const userId = isOgq ? (chat.querySelector('s')?.textContent || '').trim() : (chat.querySelector('u')?.textContent || '').trim();
const userNick = isOgq ? (chat.querySelector('sn')?.textContent || '').trim() : (chat.querySelector('n')?.textContent || '').trim();
if (!timestampStr) return;
const timestamp = parseFloat(timestampStr);
if (isNaN(timestamp) || timestamp === 0) return;
const timestampMs = Math.floor(timestamp * 1000);
const p2Num = parseInt(p2Value || '0', 10);
const subscriptionTier = ((p2Num & 0x80000) !== 0) ? 2 : 1;
const badgeType = this.getBadgeType(pValue);
const gradeValue = this.getGradeValue(pValue, subscriptionMonths);
let ogqImageUrl = null;
if (isOgq && ogqGid && ogqSid) {
const fileExtension = (ogqAnm === '1') ? 'webp' : 'png';
const ogqCdn = this._soopUrls.OGQ_STICKER_CDN_ORIGIN || 'https://ogq-sticker-global-cdn-z01.sooplive.com';
ogqImageUrl = `${ogqCdn}/sticker/${ogqGid}/${ogqSid}_80.${fileExtension}?ver=${ogqVersion || '1'}`;
}
const ogqPurchaseUrl = isOgq && ogqGid
? `${this._soopUrls.OGQ_MARKET_ORIGIN || 'https://ogqmarket.sooplive.com'}?m=detail&productId=${ogqGid}`
: null;
messages.push({
userId, userNick, msg, timestamp: timestampMs, nicknameColor,
subscriptionMonths, subscriptionTier, badgeType, gradeValue,
isOgq, ogqImageUrl, ogqPurchaseUrl
});
});
messages.sort((a, b) => a.timestamp - b.timestamp);
return messages;
} catch (error) {
this.error('XML 파싱 오류:', error);
return [];
}
}
// 채팅 DOM 요소 생성
createChatElement(chatData) {
const {
userId,
userNick,
msg,
timestamp,
nicknameColor,
subscriptionMonths,
subscriptionTier,
badgeType,
gradeValue,
isOgq,
ogqImageUrl,
ogqPurchaseUrl
} = chatData;
if (!userNick && !userId) {
this.warn('채팅 데이터에 userNick과 userId가 없습니다:', chatData);
return null;
}
const chatItem = document.createElement('div');
chatItem.className = 'chatting-list-item';
if (badgeType) {
chatItem.setAttribute('user-type', badgeType);
}
const messageContainer = document.createElement('div');
messageContainer.className = 'message-container';
const usernameDiv = document.createElement('div');
usernameDiv.className = 'username';
const button = document.createElement('button');
// 퍼스나콘 (구독 개월수 -1이면 표시 안함)
const subscriptionMonthsNum = parseInt(subscriptionMonths || '-1', 10);
if (subscriptionMonthsNum !== -1) {
const thumb = document.createElement('span');
thumb.className = 'thumb';
const img = document.createElement('img');
img.id = 'author';
if (userId) {
img.setAttribute('user_id', userId);
img.setAttribute('user_nick', userNick || '');
img.setAttribute('grade', gradeValue.toString());
const personalconUrl = this.getPersonalconUrl(subscriptionMonths, subscriptionTier || 1);
img.src = personalconUrl || `${this._soopUrls.RES_ORIGIN || 'https://res.sooplive.com'}/images/chatting/signature-default.svg`;
} else {
img.setAttribute('user_nick', userNick || '');
img.setAttribute('grade', gradeValue.toString());
img.src = `${this._soopUrls.RES_ORIGIN || 'https://res.sooplive.com'}/images/chatting/signature-default.svg`;
}
img.onerror = function() {
this.src = `${window.VODSync?.SoopUrls?.RES_ORIGIN || 'https://res.sooplive.com'}/images/chatting/signature-default.svg`;
};
thumb.appendChild(img);
button.appendChild(thumb);
}
// 배지
if (badgeType) {
const badge = document.createElement('span');
if (badgeType === 'support') {
badge.className = 'grade-badge-support';
badge.setAttribute('tip', '서포터');
badge.innerText = 'S';
} else if (badgeType === 'vip') {
badge.className = 'grade-badge-vip';
badge.setAttribute('tip', '열혈팬');
badge.innerText = '열';
} else if (badgeType === 'subscribe') {
badge.className = 'grade-badge-fan';
badge.setAttribute('tip', '팬클럽');
badge.innerText = 'F';
} else if (badgeType === 'manager') {
badge.className = 'grade-badge-manager';
badge.setAttribute('tip', '매니저');
badge.innerText = 'M';
}
badge.id = 'author';
if (userId) badge.setAttribute('user_id', userId);
badge.setAttribute('user_nick', userNick || '');
badge.setAttribute('grade', gradeValue.toString());
button.appendChild(badge);
}
// 사용자명
const author = document.createElement('span');
author.className = 'author random-color4';
author.id = 'author';
author.setAttribute('href', 'javascript:;');
if (userId) author.setAttribute('user_id', userId);
author.setAttribute('user_nick', userNick || '');
author.setAttribute('grade', gradeValue.toString());
author.innerText = userNick || '알 수 없음';
if (nicknameColor) {
author.style.color = `#${nicknameColor}`;
}
button.appendChild(author);
usernameDiv.appendChild(button);
// 메시지 텍스트
const messageTextDiv = document.createElement('div');
messageTextDiv.className = 'message-text';
// OGQ 이모티콘
if (isOgq && ogqImageUrl && ogqPurchaseUrl) {
const emoticonBox = document.createElement('div');
emoticonBox.className = 'emoticon-box';
const imgBox = document.createElement('a');
imgBox.className = 'img-box';
imgBox.setAttribute('tip', '구매하기');
imgBox.href = ogqPurchaseUrl;
imgBox.target = '_blank';
const ogqImg = document.createElement('img');
ogqImg.className = 'ogqEmoticon';
ogqImg.setAttribute('data-original-ext', ogqImageUrl.includes('.webp') ? 'webp' : 'png');
ogqImg.style.cursor = 'pointer';
ogqImg.src = ogqImageUrl;
ogqImg.onerror = function() {
this.src = `${window.VODSync?.SoopUrls?.RES_ORIGIN || 'https://res.sooplive.com'}/images/chat/ogq_default.png`;
};
imgBox.appendChild(ogqImg);
emoticonBox.appendChild(imgBox);
messageTextDiv.appendChild(emoticonBox);
}
const p = document.createElement('p');
p.className = 'msg';
p.style.color = '0';
// 시그니처 이모티콘 처리
if (msg && this.signatureEmoticon) {
this.processSignatureEmoticons(p, msg);
} else {
p.innerText = msg || '';
}
// playbackTime 커스텀 툴팁 및 클릭 시 해당 시점으로 이동
const playbackTimeSeconds = timestamp ? Math.floor(timestamp / 1000) : 0;
if (timestamp && this.initialRestoreEndTime !== null && this.sharedTooltip) {
const secondsAgo = Math.floor(this.initialRestoreEndTime - playbackTimeSeconds);
let tooltipText;
if (secondsAgo < 0) {
tooltipText = this.formatTime(playbackTimeSeconds);
} else if (secondsAgo === 0) {
tooltipText = '방금 전';
} else {
const hours = Math.floor(secondsAgo / 3600);
const minutes = Math.floor((secondsAgo % 3600) / 60);
const seconds = secondsAgo % 60;
const parts = [];
if (hours > 0) parts.push(`${hours}시간`);
if (minutes > 0) parts.push(`${minutes}분`);
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}초`);
tooltipText = `${parts.join(' ')} 전`;
}
messageTextDiv.addEventListener('mouseenter', (e) => {
if (!this.sharedTooltip) return;
if (this._tooltipHideTimeout) {
clearTimeout(this._tooltipHideTimeout);
this._tooltipHideTimeout = null;
}
const rect = messageTextDiv.getBoundingClientRect();
this.sharedTooltip.dataset.playbackTimeSeconds = String(playbackTimeSeconds);
this.sharedTooltip.textContent = tooltipText;
this.sharedTooltip.style.right = `${window.innerWidth - rect.right}px`;
this.sharedTooltip.style.top = `${rect.top - 5}px`;
this.sharedTooltip.style.opacity = '1';
this.sharedTooltip.style.pointerEvents = 'auto';
this.sharedTooltip.style.cursor = 'pointer';
});
messageTextDiv.addEventListener('mouseleave', (e) => {
if (!this.sharedTooltip) return;
if (e.relatedTarget === this.sharedTooltip) return;
this._tooltipHideTimeout = setTimeout(() => {
this._tooltipHideTimeout = null;
if (this.sharedTooltip) {
this.sharedTooltip.style.opacity = '0';
this.sharedTooltip.style.pointerEvents = 'none';
}
}, 100);
});
}
messageTextDiv.appendChild(p);
messageContainer.appendChild(usernameDiv);
messageContainer.appendChild(messageTextDiv);
chatItem.appendChild(messageContainer);
return chatItem;
}
// 채팅을 버튼 컨테이너와 다음 요소 사이에 삽입
insertChatsBelowButton(chatElements) {
if (!this.chatMemo || chatElements.length === 0) return;
const fragment = document.createDocumentFragment();
chatElements.forEach(el => fragment.appendChild(el));
// chatMemo의 첫 번째 요소 앞에 삽입
if (this.chatMemo.firstChild) {
this.chatMemo.insertBefore(fragment, this.chatMemo.firstChild);
} else {
this.chatMemo.appendChild(fragment);
}
}
// 기본 버튼 문구 (nextRestorePlan + suffix 기준, 복원 중이면 복원 중 문구)
generateRestoreButtonText(suffix = '') {
if (this.isRestoring) return `이전 채팅 복원 (${this.restoreInterval}초) - 복원 중...`;
if (!this.nextRestorePlan) return '이전 채팅 복원 준비 중';
const { startTime, endTime } = this.nextRestorePlan;
if (startTime === 0 && endTime === 0) return '영상의 시작 지점에 도달함';
return `이전 채팅 복원 (${this.restoreInterval}초)${suffix}`;
}
// 버튼 텍스트 및 상태 업데이트 (nextRestorePlan / isRestoring 기준, suffix는 매개변수로 받음)
updateButtonText(suffix = '') {
if (!this.restoreButton) return;
const atStart = this.nextRestorePlan && this.nextRestorePlan.startTime === 0 && this.nextRestorePlan.endTime === 0;
const disabled = this.isRestoring || atStart;
this.restoreButton.disabled = disabled;
if (disabled) {
this.restoreButton.style.backgroundColor = '#cccccc';
this.restoreButton.style.color = '#333333';
this.restoreButton.style.cursor = 'not-allowed';
this.restoreButton.style.opacity = '0.6';
} else {
this.restoreButton.style.backgroundColor = '#4CAF50';
this.restoreButton.style.color = 'white';
this.restoreButton.style.cursor = 'pointer';
this.restoreButton.style.opacity = '1';
}
this.restoreButton.innerText = this.generateRestoreButtonText(suffix);
if (!this.nextRestorePlan) {
this.restoreButton.title = '';
return;
}
const { startTime, endTime } = this.nextRestorePlan;
if (startTime !== undefined && endTime !== undefined) {
if (startTime === 0 && endTime === 0) {
this.restoreButton.title = '영상의 시작 지점에 도달함';
} else {
this.restoreButton.title = `다음 복원 구간: ${this.formatTime(startTime)} ~ ${this.formatTime(endTime)}`;
}
} else {
this.restoreButton.title = '';
}
}
// 초를 HH:MM:SS 형식으로 변환
formatTime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
// 시그니처 이모티콘 및 기본 이모티콘 매핑 데이터 생성
buildEmoticonReplaceMap() {
this.emoticonReplaceMap.clear();
// 시그니처 이모티콘 처리
if (this.signatureEmoticon?.data && this.signatureEmoticon?.img_path) {
const imgPath = this.signatureEmoticon.img_path;
const tier1 = this.signatureEmoticon.data.tier1 || [];
const tier2 = this.signatureEmoticon.data.tier2 || [];
const allEmoticons = [...tier1, ...tier2];
allEmoticons.forEach(emoticon => {
// move_img가 'Y'이면 pc_alternate_img 사용, 아니면 pc_img 사용
const imgFileName = emoticon.move_img === 'Y' && emoticon.pc_alternate_img
? emoticon.pc_alternate_img
: emoticon.pc_img;
const imgUrl = imgPath + imgFileName;
const imgHtml = `<img class="emoticon" src="${imgUrl}">`;
// `/이모티콘ID/` -> `<img>` HTML 매핑
this.emoticonReplaceMap.set(`/${emoticon.title}/`, imgHtml);
});
}
// 기본 이모티콘 처리
if (this.defaultEmoticon?.data) {
// default 그룹 처리
if (this.defaultEmoticon.data.default?.groups) {
const defaultGroups = this.defaultEmoticon.data.default.groups;
const defaultUrl = this.defaultEmoticon.data.default.small_url || this.defaultEmoticon.data.default.big_url;
defaultGroups.forEach(group => {
if (group.emoticons) {
group.emoticons.forEach(emoticon => {
if (!emoticon.isDeprecated && emoticon.keyword && emoticon.fileName) {
const imgUrl = defaultUrl + emoticon.fileName;
const imgHtml = `<img class="emoticon" src="${imgUrl}">`;
this.emoticonReplaceMap.set(emoticon.keyword, imgHtml);
}
});
}
});
}
// subscribe 그룹 처리
if (this.defaultEmoticon.data.subscribe?.groups) {
const subscribeGroups = this.defaultEmoticon.data.subscribe.groups;
const subscribeUrl = this.defaultEmoticon.data.subscribe.small_url || this.defaultEmoticon.data.subscribe.big_url;
subscribeGroups.forEach(group => {
if (group.emoticons) {
group.emoticons.forEach(emoticon => {
if (!emoticon.isDeprecated && emoticon.keyword && emoticon.fileName) {
// staticFileName이 있으면 사용, 없으면 fileName 사용
const imgFileName = emoticon.staticFileName || emoticon.fileName;
const imgUrl = subscribeUrl + imgFileName;
const imgHtml = `<img class="emoticon" src="${imgUrl}">`;
this.emoticonReplaceMap.set(emoticon.keyword, imgHtml);
}
});
}
});
}
}
}
// 이모티콘만으로 이루어진 메시지 여부 (복원 제외 대상 판별용)
isEmoticonOnlyMessage(chatData) {
const { msg, isOgq } = chatData;
// OGQ만 있고 텍스트가 없으면 이모티콘만
if (isOgq && (!msg || !String(msg).trim())) {
return true;
}
const text = (msg || '').trim();
if (!text) return false;
let rest = text;
this.emoticonReplaceMap.forEach((_, pattern) => {
rest = rest.split(pattern).join('');
});
return rest.trim() === '';
}
// 메시지 텍스트에서 시그니처 이모티콘 처리
processSignatureEmoticons(pElement, msgText) {
if (this.emoticonReplaceMap.size === 0) {
pElement.innerText = msgText;
return;
}
let processedText = msgText;
// 매핑 데이터를 사용하여 모든 이모티콘 교체
this.emoticonReplaceMap.forEach((imgHtml, emoticonPattern) => {
processedText = processedText.replaceAll(emoticonPattern, imgHtml);
});
// HTML로 설정
pElement.innerHTML = processedText;
}
// VOD ID 가져오기
getVideoId() {
const match = window.location.pathname.match(/\/player\/(\d+)/);
return match ? match[1] : null;
}
// 구독 개월수에 맞는 퍼스나콘 이미지 URL 가져오기
getPersonalconUrl(subscriptionMonths, subscriptionTier = 1) {
if (!this.vodInfo?.data?.subscription_personalcon) {
return null;
}
const monthsNum = parseInt(subscriptionMonths || '0', 10);
if (isNaN(monthsNum) || monthsNum < 0) return null;
const tier = subscriptionTier === 2
? this.vodInfo.data.subscription_personalcon.tier2
: this.vodInfo.data.subscription_personalcon.tier1;
if (!tier || tier.length === 0) return null;
// monthsNum 이하인 것 중 가장 큰 값
let bestMatch = null;
for (const item of tier) {
if (item.month <= monthsNum) {
if (!bestMatch || item.month > bestMatch.month) {
bestMatch = item;
}
}
}
return bestMatch?.file_name || tier[0]?.file_name || null;
}
// p 태그 값에 따른 배지 타입 결정
getBadgeType(pValue) {
const pNum = parseInt(pValue || '0', 10);
if ((pNum & 0x40) !== 0) return 'manager';
if ((pNum & 0x8000) !== 0) return 'vip';
if ((pNum & 0x20) !== 0) return 'subscribe';
if ((pNum & 0x100000) !== 0) return 'support';
return null;
}
// grade 속성 값 계산 (3: 팬클럽 이상, 6: 구독자, 5: 둘 다 아님)
getGradeValue(pValue, subscriptionMonths) {
const pNum = parseInt(pValue || '0', 10);
const monthsNum = parseInt(subscriptionMonths || '-1', 10);
const isFanClubOrVip = (pNum & 0x20) !== 0;
const isSubscriber = monthsNum !== -1;
if (isSubscriber) return 6;
if (isFanClubOrVip) return 3;
return 5;
}
// 설정 팝업 표시
showSettingsPopup() {
// 기존 팝업이 있으면 제거
if (this.settingsPopup && this.settingsPopup.parentElement) {
this.settingsPopup.remove();
}
if (!this.settingsButton) return;
const maxDuration = this.vodInfo?.data?.chat_duration;
// 설정 버튼의 위치 정보 가져오기
const buttonRect = this.settingsButton.getBoundingClientRect();
const popupWidth = 300; // min-width와 동일
const popup = document.createElement('div');
popup.style.cssText = `
position: fixed;
top: ${buttonRect.bottom}px;
right: ${window.innerWidth - buttonRect.right}px;
background: white;
border: 2px solid #4CAF50;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
z-index: 10000;
min-width: ${popupWidth}px;
`;
const title = document.createElement('div');
title.innerText = '복원 구간 설정';
title.style.cssText = 'font-size: 18px; font-weight: bold; margin-bottom: 15px;';
const label = document.createElement('div');
label.innerText = `복원 구간: ${this.restoreInterval}초`;
label.id = 'vodsync-interval-label';
label.style.cssText = 'margin-bottom: 10px; font-size: 14px;';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '10';
slider.max = String(maxDuration || 300);
slider.step = '10';
slider.value = String(this.restoreInterval);
slider.style.cssText = 'width: 100%; margin-bottom: 15px;';
slider.addEventListener('input', (e) => {
const value = parseInt(e.target.value, 10);
label.innerText = `복원 구간: ${value}초`;
});
const excludeEmoticonOnlyLabel = document.createElement('label');
excludeEmoticonOnlyLabel.style.cssText = 'display: flex; align-items: center; gap: 8px; margin-bottom: 15px; font-size: 14px; cursor: pointer;';
const excludeEmoticonOnlyCheck = document.createElement('input');
excludeEmoticonOnlyCheck.type = 'checkbox';
excludeEmoticonOnlyCheck.checked = this.excludeEmoticonOnlyChat;
excludeEmoticonOnlyLabel.appendChild(excludeEmoticonOnlyCheck);
excludeEmoticonOnlyLabel.appendChild(document.createTextNode('이모티콘만으로 이루어진 채팅 복원 제외'));
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = 'display: flex; gap: 10px; justify-content: flex-end;';
const cancelBtn = document.createElement('button');
cancelBtn.innerText = '취소';
cancelBtn.style.cssText = `
padding: 8px 16px;
background-color: #ccc;
color: black;
border: none;
border-radius: 4px;
cursor: pointer;
`;
cancelBtn.addEventListener('click', () => {
popup.remove();
});
const saveBtn = document.createElement('button');
saveBtn.innerText = '저장';
saveBtn.style.cssText = `
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
`;
saveBtn.addEventListener('click', async () => {
const newInterval = parseInt(slider.value, 10);
this.restoreInterval = newInterval;
this.excludeEmoticonOnlyChat = excludeEmoticonOnlyCheck.checked;
this.log(`복원 구간 단위 변경: ${newInterval}초, 이모티콘만 제외: ${this.excludeEmoticonOnlyChat}`);
// 설정 저장
await this.saveRestoreInterval();
// 현재 restoreTimeRange가 있으면 새로운 interval로 재계산
if (this.nextRestorePlan) {
const { endTime } = this.nextRestorePlan;
const nextStart = Math.max(0, endTime - this.restoreInterval);
this.nextRestorePlan = { startTime: nextStart, endTime: endTime };
}
popup.remove();
});
buttonContainer.appendChild(cancelBtn);
buttonContainer.appendChild(saveBtn);
popup.appendChild(title);
popup.appendChild(label);
popup.appendChild(slider);
popup.appendChild(excludeEmoticonOnlyLabel);
popup.appendChild(buttonContainer);
document.body.appendChild(popup);
this.settingsPopup = popup;
}
}
/**
* SOOP VOD 편집 UI — `button.video_edit` 직접 처리, 패널 재사용(숨김/표시).
* 확장(브리지 있음): `soop_content` 가 주입한 `VodCorePageBridge` 가 `#__vs_vodcore_ghost`에 playingTime·총 길이를 쓰고
* `data-vs-seek`로 시크해 `window.vodCore`와 맞춘다.
* 재생·시크는 `tsManager` 우선; 재생 어댑터(`window.VODSync.getVodCore`)를 통해 메타/명령을 통일한다.
* @typedef {{ name: string, begin: number, end: number, visibleOnTimeline?: boolean }} VeditorClip
* @typedef {{ startTime: number, endTime: number, duration: number, idx: number, sectionIdx: number }} VeditorApiClip
*
* 역할 맵 (편집기 뼈대 — 메서드·필드는 이 경계를 기준으로 묶인다).
*
* 1) 오버레이 수명주기 — `video_edit` 감지, 패널 표시/숨김, DOM 1회 마운트.
* 진입점: `_scanVideoEditButtons`, `_showPanel`, `_hidePanel`, `_mountOverlayDom`
*
* 2) 편집 구간 모델 — 구간 배열, 선택 인덱스, undo, 검증.
* 진입점: `_getClips`, `_clipAdd(begin,end,name?)`, `_recordClipUndo`, `_clipValidate`, `importClipsFromParsedRanges`
*
* 3) 타임라인 뷰 — px/s, 눈금·트랙·편집 구간 그래프, 휠/스크롤바, 도구 모드.
* 진입점: `_syncUiFromState`, `_renderRuler`, `_renderClipsOnTrack`, `_bindTimelineWheel`
*
* 4) 재생·시크 — tsManager·vodCore(보간)·`<video>` 플레이헤드, 연속/구간 재생 RAF.
* 진입점: `_refreshCachedGlobalPlaybackTime`, `_plSeekGlobal`, `_playAllClips`, `SoopVeditorReplacement.ClipBoundaryPlayback`
*
* 5) 게시 — 모달·카테고리·API 제출. 모달 차단: 총 길이 15초 미만 또는 30분 이상. 3초 미만 구간이 있으면 경고 alert만(모달·게시 시도는 가능). 페이로드는 유효 구간을 공식 API에 전달.
* 진입점: `_onPublishButtonClick`, `_submitPublishModal`
*
* 데이터 변경 후 UI 일괄 갱신: `_syncUiFromState()` (조율자).
*/
class SoopVeditorReplacement extends IVodSync {
static VEDITOR_API_SLOT_COUNT = 5;
static VEDITOR_HOUR_SEC = 3600;
/** px/초 — 뷰포트·총 길이를 알 수 없을 때만 사용 (전체 타임라인 맞춤 시에는 더 작게 허용). */
static MIN_PPS_ABS_FLOOR = 0.02;
static MAX_PPS = 800;
/** 휠 deltaY 를 픽셀 단위로 맞춘 뒤 `pps *= exp(-dy * 이 값)` — 작을수록 줌이 완만함. */
static ZOOM_WHEEL_EXP_PER_PX = 0.001125;
static RULER_HEIGHT_PX = 17;
static MAX_RULER_TICKS = 200;
static TIMELINE_SCROLL_THUMB_MIN_PX = 28;
static MAX_MINOR_TICKS = 600;
static CLIP_MIN_DURATION = 0.05;
/** 편집 패널 배속 드롭다운 값 (표시는 n×). */
static PLAYBACK_SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
/** 타임라인 복사 `<select>` 안내 항목 — 복사 후 이 값으로 되돌려 같은 형식을 연속 선택할 수 있게 함. */
static TIMELINE_COPY_PROMPT_VALUE = '_vs_timeline_copy_prompt';
/** 시크 후 stale 시각이 end 뒤로 남아 있을 때 종료·다음 편집 구간 오판 방지 — 이 여유 안으로 들어와야 ‘현재 편집 구간 재생 중’으로 본다. */
static PLAYBACK_CLIP_ENTRY_BEGIN_EPS = 0.15;
static PLAYBACK_CLIP_ENTRY_END_SLACK = 0.4;
/** 편집 오버레이 인라인 CSS — 탬퍼몽키는 본 클래스만 추출하므로 여기에 둔다. */
static OverlayInlineStyles = class {
static cssText() {
return ` .vs-veditor-overlay { position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 2147483000;
display: none; flex-direction: column; align-items: stretch; justify-content: flex-end; padding: 0; margin: 0;
box-sizing: border-box; pointer-events: none; }
/* 공통 떠 있는 패널 스킨 — 타임라인·편집 구간 목록은 각각 별도 엘리먼트로 shell에 나란히 붙음 */
.vs-veditor-overlay-panel {
pointer-events: auto; box-sizing: border-box; border-radius: 0;
box-shadow: 0 -6px 36px rgba(0,0,0,0.5); border: 1px solid var(--vs-border, #2a2e33); border-bottom: none; margin: 0;
max-height: min(78vh, 920px); overflow-x: hidden; overflow-y: auto;
padding: 12px 12px 14px; background: var(--vs-bg); }
.vs-veditor-dock-row {
display: flex; flex-direction: row; flex-wrap: nowrap; align-items: flex-end; align-content: flex-start;
justify-content: flex-start; gap: 0; width: 100%; pointer-events: none; }
.vs-veditor-dock-row > .vs-veditor-overlay-panel { pointer-events: auto; }
.vs-veditor-timeline-panel {
flex: 0 0 auto;
width: 75%; max-width: 75%;
min-width: 0;
padding-bottom: 4px; }
.vs-veditor-clip-panel {
flex: 0 0 auto;
width: 25%; max-width: 25%;
min-width: 0;
/* 뷰포트 높이의 약 절반 — 고정 박스, 내부만 스크롤 */
height: 50vh;
max-height: 50vh;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
align-self: flex-end; }
.vs-veditor-clip-panel.vs-collapsed {
height: auto; max-height: none; overflow: visible; }
.vs-veditor-clip-panel.vs-collapsed > .vs-veditor-clip-col {
flex: none; overflow: visible; min-height: auto; }
.vs-veditor-clip-panel.vs-collapsed .vs-veditor-clip-scroll {
display: none; }
.vs-veditor-clip-panel-head {
font-weight: 600; font-size: 13px; margin: 0 0 8px; color: #e8eaed; letter-spacing: 0.02em;
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
.vs-veditor-clip-panel-head-actions {
display: flex; align-items: center; gap: 6px; flex-shrink: 1; min-width: 0; justify-content: flex-end; }
.vs-veditor-clip-panel-head .vs-veditor-playback-speed.vs-veditor-timeline-copy-action {
flex: 1 1 auto; width: auto; min-width: 9em; max-width: 15em; max-height: 26px; box-sizing: border-box; }
.vs-veditor-clip-panel-toggle {
padding: 2px 8px; min-height: 22px; border-radius: 4px; font-size: 12px; flex: 0 0 auto; }
.vs-veditor-clip-panel > .vs-veditor-json-toolbar { flex-shrink: 0; }
.vs-veditor-root {
--vs-bg: #0c0d10;
--vs-panel: #12141a;
--vs-border: #2a2e33;
--vs-muted: #8b95a5;
--vs-accent: #00d4e8;
--vs-accent-dim: #0099aa;
--vs-playhead-glow: rgba(0, 212, 232, 0.35);
--vs-clip-fill: rgba(0, 140, 130, 0.42);
--vs-clip-fill-sel: rgba(0, 180, 170, 0.52);
--vs-clip-border: #00a896;
--vs-clip-border-sel: #40d4c8;
box-sizing: border-box; font-family: inherit; color: #e8eaed; background: var(--vs-bg);
border: none; border-radius: 0; padding: 0; margin: 0; width: 100%; max-width: 100%;
position: relative; z-index: 1; display: flex; flex-direction: column; gap: 8px; min-width: 0;
background: transparent; }
.vs-veditor-clip-panel .vs-veditor-json-panel {
display: none; margin-top: 8px; flex-shrink: 1; min-height: 0; max-height: 28vh; overflow: auto; }
.vs-veditor-clip-panel .vs-veditor-json-panel.vs-open { display: block; }
.vs-veditor-clip-panel .vs-veditor-json-toolbar { margin-top: 6px; }
.vs-veditor-root * { box-sizing: border-box; }
.vs-veditor-title-head { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.vs-veditor-title { font-weight: 600; margin-bottom: 0; font-size: 14px; flex: 0 1 auto; min-width: 0; }
.vs-veditor-title-row { display: flex; align-items: center; justify-content: space-between; gap: 10px;
margin-bottom: 6px; flex-wrap: wrap; position: relative; }
.vs-veditor-title-actions { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
.vs-veditor-seq-col {
width: 100%; min-width: 0;
display: flex; flex-direction: column; gap: 6px;
height: max-content; max-height: max-content; overflow: hidden;
contain: layout; }
.vs-veditor-clip-col {
width: 100%; min-width: 0; min-height: 0;
flex: 1 1 0;
display: flex; flex-direction: column; gap: 6px;
overflow: hidden; }
.vs-veditor-seq-header {
flex: 0 0 34px; width: 34px; min-width: 34px;
display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 6px;
padding: 4px; background: var(--vs-panel); border: 1px solid var(--vs-border); border-radius: 4px;
font-size: 12px; color: var(--vs-muted); }
.vs-veditor-timecode { font-family: ui-monospace, monospace; color: var(--vs-accent); font-size: 12px; }
.vs-veditor-seq-head-right { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; }
.vs-veditor-clip-toolbar {
display: flex; flex-direction: column; gap: 6px; padding: 6px; background: var(--vs-panel);
border: 1px solid var(--vs-border); border-radius: 4px; flex-shrink: 0; }
.vs-veditor-clip-toolbar-row { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.vs-veditor-title-inline-actions {
justify-content: center; padding: 0; margin: 0;
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
background: transparent; border: none; }
.vs-veditor-clip-total { font-size: 14px; color: var(--vs-muted); margin-left: auto; }
.vs-veditor-clip-play-status {
font-size: 12px; color: #ffd8d8; margin-left: 8px; white-space: nowrap; }
.vs-veditor-clip-scroll {
flex: 1 1 0;
min-height: 0;
overflow: auto;
border: 1px solid var(--vs-border); border-radius: 4px; background: #0a0b0e; padding: 4px; }
.vs-veditor-clip-list-empty { padding: 12px; font-size: 12px; color: var(--vs-muted); text-align: center; }
.vs-veditor-clip-row {
border: 1px solid var(--vs-border); border-radius: 4px; margin-bottom: 6px; padding: 6px;
background: #15171c; cursor: pointer; }
.vs-veditor-clip-row.vs-veditor-clip-add-row {
cursor: default; display: flex; align-items: center; justify-content: center;
margin-bottom: 0; min-height: 0; }
.vs-veditor-clip-add-btn {
width: 30px; height: 30px; min-width: 30px; min-height: 30px;
border-radius: 50%; padding: 0; margin: 0;
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 300; line-height: 1; font-family: system-ui, sans-serif;
color: var(--vs-accent);
background: rgba(0, 212, 232, 0.12);
border: 2px dashed var(--vs-accent-dim);
cursor: pointer;
box-sizing: border-box;
transition: background 0.12s, border-color 0.12s, transform 0.12s; }
.vs-veditor-clip-add-btn:hover {
background: rgba(0, 212, 232, 0.22);
border-style: solid;
border-color: var(--vs-accent);
transform: scale(1.04); }
.vs-veditor-clip-add-btn:active { transform: scale(0.98); }
.vs-veditor-clip-row--selected {
border-color: var(--vs-clip-border-sel); box-shadow: 0 0 0 1px rgba(0, 180, 170, 0.25); }
.vs-veditor-clip-row-line {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-width: 0; }
.vs-veditor-clip-row-left {
display: flex; align-items: center; gap: 6px; min-width: 0; }
.vs-veditor-clip-row-center {
display: flex; align-items: center; justify-content: center; gap: 4px;
flex-wrap: nowrap; }
.vs-veditor-clip-row-right {
display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
.vs-veditor-clip-name { flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 3px 5px;
background: #1a1d24; border: 1px solid #3d4450; color: #e8eaed; border-radius: 3px; }
.vs-veditor-clip-dur {
flex: 0 0 auto; font-size: 12px; color: var(--vs-muted);
font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; }
.vs-veditor-clip-drag-handle {
flex: 0 0 auto;
width: 22px; min-width: 22px; height: 22px;
display: inline-flex; align-items: center; justify-content: center;
border: none; border-radius: 4px; background: transparent; color: #9aa4b5;
cursor: grab; user-select: none; padding: 0; font-size: 12px; line-height: 1; }
.vs-veditor-clip-drag-handle:active { cursor: grabbing; }
.vs-veditor-clip-drag-handle:disabled { opacity: 0.45; cursor: default; }
.vs-veditor-clip-time-tilde {
flex: 0 0 auto; color: var(--vs-muted); font-size: 13px; line-height: 1;
font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
user-select: none; padding: 0 1px; }
.vs-veditor-clip-time-inp {
flex: 0 0 auto; min-width: 11.5ch; width: 12ch; max-width: 100%;
font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
font-size: 13px; line-height: 1.3;
text-align: center;
padding: 5px 6px;
background: #0d1117; border: 1px solid #4a5568; color: #e8eaed; border-radius: 4px;
outline: none; box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
transition: border-color 0.12s, box-shadow 0.12s; }
.vs-veditor-clip-time-inp:hover { border-color: #6b7585; }
.vs-veditor-clip-time-inp:focus {
border-color: var(--vs-accent);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 0 0 2px rgba(0, 212, 232, 0.22); }
.vs-veditor-clip-time-inp::-webkit-outer-spin-button,
.vs-veditor-clip-time-inp::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.vs-veditor-clip-time-inp[type="number"] { -moz-appearance: textfield; appearance: textfield; }
.vs-veditor-btn-icon { padding: 2px 8px; min-width: 2em; }
.vs-veditor-clip-eye-btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 2px 4px; min-width: 28px; min-height: 26px; box-sizing: border-box; }
.vs-veditor-clip-eye-btn svg { display: block; width: 16px; height: 16px; flex-shrink: 0; }
.vs-veditor-clip-eye-btn svg,
.vs-veditor-clip-eye-btn svg * { pointer-events: none; }
.vs-veditor-clip-eye-btn--off { color: #8b95a8; }
.vs-veditor-timeline-dock {
width: 100%; min-width: 0; flex: 0 0 auto; flex-grow: 0; flex-shrink: 0;
display: flex; flex-direction: row; align-items: stretch; gap: 6px;
height: max-content; max-height: max-content; overflow: visible; }
.vs-veditor-timeline-graph-col {
flex: 1 1 auto; min-width: 0;
display: flex; flex-direction: column; gap: 6px; }
.vs-veditor-timeline-viewport { display: block; width: 100%; max-width: none; overflow-x: hidden; overflow-y: hidden;
height: 66px; max-height: 66px; min-height: 66px; background: #0a0b0e; border: 1px solid var(--vs-border);
border-radius: 4px; position: relative; flex-grow: 0; flex-shrink: 0; }
.vs-veditor-timeline-scroll-wrap { width: 100%; margin-top: 0; flex-shrink: 0; user-select: none; }
.vs-veditor-timeline-scroll-track { position: relative; height: 14px; border-radius: 7px; background: #1e2228;
border: 1px solid #3d4450; cursor: pointer; box-sizing: border-box; }
.vs-veditor-timeline-scroll-thumb { position: absolute; top: 1px; height: calc(100% - 2px); left: 0;
min-width: 28px; border-radius: 6px; background: linear-gradient(180deg, #4a7a82 0%, #3a5c62 100%);
border: 1px solid #5a9098; box-sizing: border-box; cursor: grab; touch-action: none; }
.vs-veditor-timeline-scroll-thumb:active { cursor: grabbing; }
.vs-veditor-timeline-scroll-wrap.vs-disabled .vs-veditor-timeline-scroll-thumb { cursor: default; opacity: 0.85; }
.vs-veditor-timeline-inner { position: relative; height: 66px; min-height: 66px; max-height: 66px;
overflow: hidden;
box-sizing: border-box; }
.vs-veditor-ruler { position: absolute; left: 0; top: 0; right: 0; height: 17px; z-index: 6;
pointer-events: auto; }
.vs-veditor-tick-major { position: absolute; top: 0; bottom: 0; border-left: 1px solid #4a5568; padding-left: 2px; }
.vs-veditor-tick-minor { position: absolute; top: 10px; bottom: 0; left: 0; width: 0; border-left: 1px solid #2f3540;
padding: 0; pointer-events: none; }
.vs-veditor-track { position: absolute; left: 0; right: 0; top: 19px; bottom: 3px; background: #14181d;
pointer-events: auto; }
.vs-veditor-playhead { position: absolute; top: 0; bottom: 0; width: 13px; margin-left: -6px; z-index: 12;
pointer-events: auto; cursor: ew-resize; touch-action: none; }
.vs-veditor-playhead-line { position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; margin-left: -1px;
background: var(--vs-accent); box-shadow: 0 0 6px var(--vs-playhead-glow); pointer-events: none; }
.vs-veditor-playhead-head { position: absolute; left: 50%; top: 0; width: 11px; height: 15px; margin-left: -5px;
background: linear-gradient(180deg, #33e4f5 0%, var(--vs-accent) 100%); border-radius: 2px 2px 1px 1px;
border: 1px solid var(--vs-accent-dim); pointer-events: none; box-shadow: 0 1px 4px rgba(0,0,0,0.45); }
.vs-veditor-clip { position: absolute; top: 4px; bottom: 4px; background: var(--vs-clip-fill);
border: 1px solid var(--vs-clip-border); border-radius: 2px; pointer-events: auto; z-index: 1; }
.vs-veditor-clip.vs-selected { background: var(--vs-clip-fill-sel); border-color: var(--vs-clip-border-sel); z-index: 2; }
.vs-veditor-clip-order {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
max-width: calc(100% - 18px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 12px; font-weight: 700; color: #e8f8f6; text-shadow: 0 1px 2px rgba(0,0,0,0.85);
pointer-events: none; z-index: 3; }
.vs-veditor-clip-handle { position: absolute; top: 0; bottom: 0; width: 8px; max-width: 35%;
cursor: ew-resize; z-index: 2; background: rgba(255,255,255,0.1); }
.vs-veditor-clip-handle:hover { background: rgba(255,255,255,0.22); }
.vs-veditor-clip-handle.vs-left { left: 0; border-radius: 2px 0 0 2px; }
.vs-veditor-clip-handle.vs-right { right: 0; border-radius: 0 2px 2px 0; }
.vs-veditor-clip-body { position: absolute; left: 8px; right: 8px; top: 0; bottom: 0; cursor: grab; z-index: 1; }
.vs-veditor-clip-body:active { cursor: grabbing; }
.vs-veditor-btn { padding: 4px 10px; border-radius: 4px; border: 1px solid #3d4450; background: #1e2228; color: #e8eaed; cursor: pointer; font-size: 12px; }
.vs-veditor-btn:hover:not(:disabled) { background: #2a3038; border-color: #4a5568; }
.vs-veditor-btn:disabled { opacity: 0.42; cursor: not-allowed; pointer-events: none; }
.vs-veditor-btn.vs-veditor-btn-danger {
background: #8a2020; border-color: #b13a3a; color: #fff2f2; font-weight: 600; }
.vs-veditor-btn.vs-veditor-btn-danger:hover:not(:disabled) {
background: #a12828; border-color: #c54b4b; }
.vs-veditor-btn.vs-veditor-btn-primary {
background: #007bff; border-color: #4ea4ff; color: white; font-weight: 600; }
.vs-veditor-btn.vs-veditor-btn-primary:hover:not(:disabled) {
background: #3395ff; border-color: #7ab8ff; color: white; }
.vs-veditor-publish-modal {
position: fixed; inset: 0; z-index: 2147483646; display: none; align-items: center; justify-content: center;
background: rgba(2, 4, 8, 0.56); pointer-events: auto; }
.vs-veditor-publish-modal.vs-open { display: flex; }
.vs-veditor-publish-card {
width: min(520px, calc(100vw - 20px)); max-height: calc(100vh - 30px); overflow: auto;
background: #2a2e34; border: 1px solid #424952; border-radius: 4px; padding: 14px; }
.vs-veditor-publish-head {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; font-size: 14px; font-weight: 600; }
.vs-veditor-publish-close {
border: none; background: transparent; color: #f3f5f8; cursor: pointer; font-size: 18px; padding: 0 4px; line-height: 1; }
.vs-veditor-publish-grid { display: flex; flex-direction: column; gap: 10px; }
.vs-veditor-publish-label { font-size: 12px; color: #dce2ea; margin-bottom: 4px; display: inline-block; }
.vs-veditor-publish-required { color: #ff5f5f; margin-left: 2px; }
.vs-veditor-publish-input,
.vs-veditor-publish-select,
.vs-veditor-publish-textarea {
width: 100%; background: #51565e; color: #f2f4f7; border: 1px solid #7a828f; border-radius: 2px; font-size: 12px; }
.vs-veditor-publish-input,
.vs-veditor-publish-select { height: 34px; padding: 0 10px; }
.vs-veditor-publish-textarea { min-height: 86px; resize: vertical; padding: 8px 10px; }
/* 제목 입력은 내용 입력과 동일한 시각 톤으로 고정 */
.vs-veditor-publish-input.vs-veditor-publish-title {
background: #51565e; border: 1px solid #7a828f; color: #f2f4f7; padding: 8px 10px; }
.vs-veditor-publish-input::placeholder,
.vs-veditor-publish-textarea::placeholder { color: #b7bec9; }
.vs-veditor-publish-input:focus,
.vs-veditor-publish-select:focus,
.vs-veditor-publish-textarea:focus {
outline: none; border-color: #9db4d5; box-shadow: 0 0 0 2px rgba(157,180,213,0.18); }
.vs-veditor-publish-category-row { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.vs-veditor-publish-desc {
margin-top: 8px; font-size: 12px; line-height: 1.5; color: #ff4f4f; white-space: pre-line; }
.vs-veditor-publish-err {
margin-top: 6px; font-size: 12px; color: #ff9a9a; min-height: 1.3em; }
.vs-veditor-publish-actions { margin-top: 8px; display: flex; justify-content: center; gap: 8px; }
.vs-veditor-publish-cancel { min-width: 68px; background: #1f2328; }
.vs-veditor-publish-submit { min-width: 68px; background: #2b8cff; border-color: #4ea4ff; color: #f7fbff; }
.vs-veditor-timeline-tools { display: flex; flex-direction: column; align-items: stretch; gap: 4px; margin-right: 0; }
.vs-veditor-timeline-label-mode,
.vs-veditor-playback-speed {
width: 100%; min-width: 0; max-width: none; height: 24px; padding: 0 5px;
border-radius: 4px; border: 1px solid #3d4450; background: #12161d; color: #d9deea; font-size: 12px; }
.vs-veditor-clip-toolbar-row .vs-veditor-playback-speed {
width: auto; min-width: 5em; max-width: 7.5em; flex: 0 0 auto; }
.vs-veditor-btn.vs-veditor-tool-btn {
width: 24px; min-width: 24px; max-width: 24px;
padding: 2px; min-height: 24px; display: inline-flex; align-items: center; justify-content: center; gap: 0;
font-size: 12px; color: #b9c3d2; }
.vs-veditor-tool-btn svg { width: 14px; height: 14px; display: block; }
.vs-veditor-tool-btn.vs-active {
color: var(--vs-accent); border-color: var(--vs-accent-dim);
box-shadow: inset 0 0 0 1px rgba(0, 212, 232, 0.14); }
.vs-veditor-track.vs-tool-cut .vs-veditor-clip,
.vs-veditor-track.vs-tool-cut .vs-veditor-clip-body,
.vs-veditor-track.vs-tool-cut .vs-veditor-clip-handle {
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M11 3h7l3 3v12l-3 3h-7l-3-3V6z' fill='%2315191f' stroke='%23cfd7e4' stroke-width='1.35'/%3E%3Cpath d='M12.7 8h5M12.7 12h4M12.7 16h5' stroke='%23cfd7e4' stroke-width='1.35' stroke-linecap='round'/%3E%3Cpath d='M4 1.5v21' stroke='%2300d4e8' stroke-width='1.8'/%3E%3C/svg%3E") 4 12, crosshair; }
.vs-veditor-json { width: 100%; min-height: 72px; font-size: 12px; font-family: monospace; background: #0a0b0e; color: #a8b0bc;
border: 1px solid var(--vs-border); border-radius: 4px; padding: 6px; }
.vs-veditor-ruler-hover-tip { position: fixed; z-index: 2147483640; display: none; pointer-events: none;
padding: 2px 6px; border-radius: 4px; background: #1e2228; border: 1px solid #4a5568; font-size: 12px; color: #e8eaed; }
`;
}
};
static ClipBoundaryPlayback = class {
/**
* @param {SoopVeditorReplacement} editor
* @param {number} t
* @param {number} begin
* @param {number} end
* @returns {'continue'|'segment_end'}
*/
static advance(editor, t, begin, end) {
const eps = SoopVeditorReplacement.PLAYBACK_CLIP_ENTRY_BEGIN_EPS;
const slack = SoopVeditorReplacement.PLAYBACK_CLIP_ENTRY_END_SLACK;
if (!editor._playbackClipEntered) {
if (t >= begin - eps && t <= end + slack) {
editor._playbackClipEntered = true;
}
return 'continue';
}
if (t >= end - 0.05) return 'segment_end';
return 'continue';
}
};
static EDITOR_RULER_STEPS_SEC = [
0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
];
/** 타임라인 표시 토글 아이콘 — 고정 문자열 재사용(행마다 새 문자열 조립 안 함). */
static CLIP_TIMELINE_VISIBILITY_SVG_ON =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M1.75 8C3.4 5.15 5.55 3.5 8 3.5 10.45 3.5 12.6 5.15 14.25 8 12.6 10.85 10.45 12.5 8 12.5 5.55 12.5 3.4 10.85 1.75 8z"/>' +
'<circle cx="8" cy="8" r="2"/></svg>';
static CLIP_TIMELINE_VISIBILITY_SVG_OFF =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M1.75 8.1Q8 5.55 14.25 8.1"/></svg>';
static TIMELINE_TOOL_SVG_SELECT =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M3 2.5L11.5 8.1 7.8 8.9 9.9 13.5 8.1 14.2 6 9.6 3 12V2.5z"/></svg>';
static TIMELINE_TOOL_SVG_CUT =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M5.2 2.2H11.9L13.7 4V11.8L11.9 13.6H5.2L3.4 11.8V4z"/><path d="M6.4 5.4h4.4M6.4 8h3.5M6.4 10.6h4.4"/><path d="M1.2 1.2v13.6"/></svg>';
// 필드 초기화, 핸들러 bind, MutationObserver로 video_edit 버튼 스캔을 시작한다 (확장 로드 직후).
constructor() {
super();
/** @type {string|null} */
this.titleNo = null;
this._panelVisible = false;
/** @type {VeditorClip[]} */
this._clips = [];
this._selectedClipIndex = 0;
/** @type {{ clips: VeditorClip[], selectedClipIndex: number }[]} */
this._clipUndoStack = [];
this._clipUndoMaxDepth = 100;
/** @type {'select'|'cut'} */
this._timelineToolMode = 'select';
/** @type {'index'|'name'} */
this._timelineClipLabelMode = 'index';
/** @type {{ clip: VeditorClip, mode: string, startX: number, origBegin: number, origEnd: number, total: number, undoSnapshot: { clips: VeditorClip[], selectedClipIndex: number }|null, dragEl: HTMLElement|null, moveRaf: number, pendingClientX: number }|null} */
this._clipDrag = null;
this.rootEl = null;
this._overlayShell = null;
/** @type {HTMLDivElement|null} */
this._clipListScrollEl = null;
/** @type {HTMLElement|null} */
this._clipPanelEl = null;
/** @type {HTMLButtonElement|null} */
this._clipPanelToggleBtn = null;
this._clipPanelCollapsed = false;
/** @type {HTMLElement|null} */
this._sequenceHeaderEl = null;
/** @type {HTMLElement|null} */
this._timecodeEl = null;
/** @type {HTMLElement|null} */
this._clipTotalEl = null;
/** @type {HTMLElement|null} */
this._clipPlayStatusEl = null;
/** @type {HTMLSelectElement|null} */
this._timelineCopyActionSel = null;
/** @type {HTMLButtonElement|null} */
this._clipToolbarFitBtn = null;
/** @type {HTMLButtonElement|null} */
this._clipToolbarDupBtn = null;
/** @type {HTMLButtonElement|null} */
this._clipToolbarDelBtn = null;
/** @type {HTMLButtonElement|null} */
this._clipToolbarStartBtn = null;
/** @type {HTMLButtonElement|null} */
this._clipToolbarEndBtn = null;
/** @type {HTMLButtonElement|null} */
this._clipToolbarAddBtn = null;
/** @type {HTMLButtonElement|null} */
this._clipToolbarPlaySelBtn = null;
/** @type {HTMLButtonElement|null} */
this._clipToolbarTestBtn = null;
/** @type {HTMLButtonElement|null} */
this._clipToolbarPublishBtn = null;
/** @type {HTMLButtonElement|null} */
this._officialVeditorBtn = null;
/** @type {HTMLDivElement|null} */
this._publishModalEl = null;
/** @type {HTMLSelectElement|null} */
this._publishBoardSel = null;
/** @type {HTMLSelectElement|null} */
this._publishVodCategorySel = null;
/** @type {HTMLSelectElement|null} */
this._publishVodCategorySubSel = null;
/** @type {HTMLSelectElement|null} */
this._publishLangSel = null;
/** @type {HTMLInputElement|null} */
this._publishTitleInp = null;
/** @type {HTMLTextAreaElement|null} */
this._publishContentsInp = null;
/** @type {HTMLElement|null} */
this._publishErrEl = null;
/** @type {HTMLButtonElement|null} */
this._publishSubmitBtn = null;
this._publishSubmitting = false;
this._publishVodCategoryTree = [];
/** @type {HTMLButtonElement|null} */
this._timelineToolSelectBtn = null;
/** @type {HTMLButtonElement|null} */
this._timelineToolCutBtn = null;
/** @type {HTMLSelectElement|null} */
this._timelineLabelModeSelect = null;
/** @type {HTMLSelectElement|null} */
this._playbackSpeedSelect = null;
this._playAllMode = false;
/** 선택한 편집 구간만 재생 중일 때 true — RAF·grace는 `_playAllRaf` 등 재사용. */
this._playSingleClipMode = false;
this._playSingleClipBeginSec = 0;
this._playSingleClipEndSec = 0;
/** 시크 직후 `t >= end` 오판 방지: 현재 편집 구간 begin 근처~end+slack 안으로 들어온 뒤에만 true */
this._playbackClipEntered = false;
/** @type {number|null} */
this._playAllRaf = null;
this._playAllSeekGraceUntil = 0;
/** @type {number} */
this._playAllIndex = 0;
this._listDnDIndex = null;
/** DnD 로 순서만 바뀐 뒤 전체 재빌드(행 DOM 순서·dataset 일치). */
this._clipListReorderPending = false;
/** `.vs-veditor-clip-scroll` 에 편집 구간 목록 위임 리스너 1회만 부착. */
this._clipListDelegationBound = false;
this._timelineViewport = null;
this._timelineInner = null;
this._rulerEl = null;
this._trackEl = null;
this._playheadEl = null;
this._rulerHoverTipEl = null;
this._pixelsPerSecond = 80;
/** 첫 레이아웃에서 타임라인 줌을 뷰포트에 맞는 최대 축소로 맞출 때까지 true */
this._veditorInitialTimelineZoomPending = true;
this._playheadSec = 0;
this._playheadDragging = false;
this._playheadRafId = null;
this._playheadVideoBound = null;
this._wheelPaintRaf = 0;
/** 스크롤·썸 드래그로 눈금+커스텀 스크롤바 갱신을 한 프레임으로 묶음 */
this._viewportScrollVisualRaf = null;
/** 눈금 호버 툴팁: mousemove 를 rAF 로 합쳐 getBoundingClientRect 폭주 방지 */
this._rulerTipRaf = null;
/** @type {MouseEvent|null} */
this._rulerTipPendingEv = null;
this._viewportResizeObs = null;
this._timelineScrollBarEl = null;
this._timelineScrollTrackEl = null;
this._timelineScrollThumbEl = null;
/** @type {{ pointerId: number, startX: number, startScroll: number, maxScroll: number, maxThumbLeft: number }|null} */
this._scrollThumbDrag = null;
/** `_refreshCachedGlobalPlaybackTime` 결과 — 재생 헤드 갱신·시크 직후 등에서만 refresh 후, 나머지는 이 값만 읽는다. */
this._cachedGlobalPlaybackSec = 0;
/** 동일 동기 스택에서 `_refreshCachedGlobalPlaybackTime` 재진입 시 1회만 읽기. */
this._gpGlobalPlaybackReadCoalesced = false;
/** 재생 시간이 잠깐 멈출 때 재생 헤드 표시만 시계로 보간 (편집 시각은 `_cachedGlobalPlaybackSec`). */
this._phExRaw = null;
this._phExAnchorSec = 0;
this._phExWallMs = 0;
this._onVideoPauseSeekForPlayhead = this._onVideoPauseSeekForPlayhead.bind(this);
this._onPlayheadPointerMove = this._onPlayheadPointerMove.bind(this);
this._onPlayheadPointerUp = this._onPlayheadPointerUp.bind(this);
this._onPlayheadKeydown = this._onPlayheadKeydown.bind(this);
this._onClipResizeMove = this._onClipResizeMove.bind(this);
this._onClipResizeEnd = this._onClipResizeEnd.bind(this);
this._tickPlayheadPanelSync = this._tickPlayheadPanelSync.bind(this);
this._onVideoEditClick = this._onVideoEditClick.bind(this);
this._onTimelineScrollThumbUp = this._onTimelineScrollThumbUp.bind(this);
this._onClipListChange = this._onClipListChange.bind(this);
this._onClipListHostClick = this._onClipListHostClick.bind(this);
this._onClipListHostDragStart = this._onClipListHostDragStart.bind(this);
this._onClipListHostDragOver = this._onClipListHostDragOver.bind(this);
this._onClipListHostDrop = this._onClipListHostDrop.bind(this);
this._onClipListHostDragEnd = this._onClipListHostDragEnd.bind(this);
this._editButtonObserver = new MutationObserver(() => this._scanVideoEditButtons());
this._editButtonObserver.observe(document.documentElement, { childList: true, subtree: true });
this._scanVideoEditButtons();
window.VODSync = window.VODSync || {};
window.VODSync.soopVeditorReplacement = this;
this.debug('SoopVeditorReplacement: ready');
}
/**
* 타임라인 댓글 등에서 파싱한 구간을 편집 구간 목록에 한 번에 추가한다. 패널이 없으면 연다.
* @param {{ begin: number, end: number, name?: string }[]} items
*/
importClipsFromParsedRanges(items) {
if (!Array.isArray(items) || items.length === 0) return;
if (!/\/player\/\d+/.test(window.location.pathname)) return;
const titleNo = window.location.pathname.match(/\/player\/(\d+)/)?.[1];
if (!titleNo) return;
this.titleNo = titleNo;
if (this._isClipListBusy()) {
window.alert('구간 테스트·연속 재생 중에는 가져올 수 없습니다. 먼저 재생을 멈춰 주세요.');
return;
}
if (!this._overlayShell) {
this._mountOverlayDom();
this._panelVisible = true;
this._veditorInitialTimelineZoomPending = true;
this._hydratePlaylistFromSource();
if (this._overlayShell) this._overlayShell.style.display = 'flex';
} else if (!this._panelVisible || this._overlayShell.style.display === 'none') {
this._showPanel();
}
this._recordClipUndo();
for (const it of items) {
const nm = it?.name != null && String(it.name).trim() !== '' ? String(it.name).trim() : undefined;
this._clipAdd(it.begin, it.end, nm);
}
const clips = this._getClips();
this._selectedClipIndex = Math.max(0, clips.length - 1);
this._syncUiFromState();
}
// --- 오버레이·진입 (역할 맵: video_edit, 패널, vodCore 파사드) ---
/** 확장/TM 공통 vodCore 파사드. 상세 접근 분기는 `window.VODSync.getVodCore()` 뒤로 숨긴다. */
_getVodCore() {
return window.VODSync?.getVodCore?.() ?? null;
}
// DOM에 있는 video_edit 버튼을 모두 찾아 아직 미바인딩인 것만 연결한다 (Observer 콜백·초기 스캔).
_scanVideoEditButtons() {
document.querySelectorAll('button.video_edit').forEach((btn) => this._bindVideoEditButton(btn));
}
// video_edit 버튼에 캡처 단계 클릭 리스너를 한 번만 붙인다 (중복 방지용 data 속성 사용).
_bindVideoEditButton(btn) {
if (!(btn instanceof HTMLButtonElement)) return;
if (btn.dataset.vsVodEditBound === '1') return;
btn.dataset.vsVodEditBound = '1';
btn.addEventListener('click', this._onVideoEditClick, true);
}
// 플레이어 페이지에서 편집 버튼 클릭 시 기본 동작을 막고 오버레이를 최초 생성하거나 토글한다.
_onVideoEditClick(e) {
if (!/\/player\/\d+/.test(window.location.pathname)) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const titleNo = window.location.pathname.match(/\/player\/(\d+)/)?.[1];
if (!titleNo) return;
this.titleNo = titleNo;
if (!this._overlayShell) {
this._mountOverlayDom();
// `_hydratePlaylistFromSource` 안의 `_startPlayheadRaf` 가 `_panelVisible` 을 본다. 먼저 true 로 두어야 최초 오픈 시에도 rAF 가 돈다.
this._panelVisible = true;
this._veditorInitialTimelineZoomPending = true;
this._hydratePlaylistFromSource();
if (this._overlayShell) this._overlayShell.style.display = 'flex';
return;
}
if (this._panelVisible) {
this._hidePanel();
} else {
this._showPanel();
}
}
// 재생 어댑터 값과 URL 기준 titleNo를 맞춘 뒤 UI를 갱신한다 (패널 최초 오픈·표시 시).
_hydratePlaylistFromSource() {
const vc = this._getVodCore();
const titleNo = vc?.config?.titleNo != null ? String(vc.config.titleNo) : '';
if (titleNo !== '') this.titleNo = String(titleNo);
const playingTime = vc?.playerController?.playingTime;
const hasPlayingTime = typeof playingTime === 'number' && Number.isFinite(playingTime);
const cfg = vc?.config;
const cfgSum = cfg?.configFilesDurationSum != null ? String(cfg.configFilesDurationSum) : '';
const fiSum = cfg?.fileItemsDurationSum != null ? String(cfg.fileItemsDurationSum) : '';
const totalDur = cfg?.totalFileDuration != null ? String(cfg.totalFileDuration) : '';
const hasVodCoreData =
vc &&
(hasPlayingTime || cfgSum !== '' || fiSum !== '' || totalDur !== '');
if (!vc) {
this.debug('vodCore 어댑터 없음 — 타임라인·시크는 tsManager·<video> 폴백');
} else if (!hasVodCoreData) {
this.debug('vodCore 브리지 대기 중 — 재생 길이는 video 메타에 의존할 수 있음');
} else {
this.debug('playlist via vodCore page bridge');
}
this._syncOfficialVeditorButtonState();
this._syncPlaybackSpeedSelectFromPlayer();
this._syncUiFromState();
this._startPlayheadRaf();
}
/** 공식 편집기 버튼: titleNo 가 있을 때만 활성화 (vodCore·URL 동기화 후 상태 맞춤). */
_syncOfficialVeditorButtonState() {
const btn = this._officialVeditorBtn;
if (!btn) return;
const ok = String(this.titleNo || '').trim().length > 0;
btn.disabled = !ok;
btn.title = ok ? 'SOOP 공식 웹 편집기(새 탭)' : 'titleNo를 알 수 없어 공식 편집기를 열 수 없습니다.';
}
// 편집 패널을 숨기고 재생 헤드 RAF·드래그 상태를 정리한다 (닫기·토글 시).
_hidePanel() {
this._panelVisible = false;
this._stopPlayAll();
this._closePublishModal();
this._onPlayheadPointerUp();
this._onTimelineScrollThumbUp();
this._stopPlayheadRaf();
if (this._viewportScrollVisualRaf != null) {
cancelAnimationFrame(this._viewportScrollVisualRaf);
this._viewportScrollVisualRaf = null;
}
if (this._rulerTipRaf != null) {
cancelAnimationFrame(this._rulerTipRaf);
this._rulerTipRaf = null;
}
this._rulerTipPendingEv = null;
this._resetPlayheadExtrap();
if (this._overlayShell) this._overlayShell.style.display = 'none';
}
_showPanel() {
this._panelVisible = true;
this._veditorInitialTimelineZoomPending = true;
if (this._overlayShell) {
this._overlayShell.style.display = 'flex';
this._hydratePlaylistFromSource();
}
}
// --- 편집 구간 모델 (배열, undo, 검증) ---
// 편집 중인 편집 구간 배열 참조를 반환한다 (테이블·트랙 렌더링에서 공통 접근).
_getClips() {
return this._clips;
}
_isClipListBusy() {
return this._playAllMode || this._playSingleClipMode;
}
_cloneClipForUndo(clip) {
return {
name: String(clip?.name ?? ''),
begin: this._roundClipSec(Number(clip?.begin ?? 0)),
end: this._roundClipSec(Number(clip?.end ?? 0)),
visibleOnTimeline: clip?.visibleOnTimeline !== false,
};
}
_snapshotClipStateForUndo() {
return {
clips: this._clips.map((c) => this._cloneClipForUndo(c)),
selectedClipIndex: this._selectedClipIndex,
};
}
_recordClipUndo() {
const snapshot = this._snapshotClipStateForUndo();
this._clipUndoStack.push(snapshot);
if (this._clipUndoStack.length > this._clipUndoMaxDepth) {
this._clipUndoStack.splice(0, this._clipUndoStack.length - this._clipUndoMaxDepth);
}
}
/**
* 레거시 `{ startTime, endTime }` 또는 불완전 필드를 `{ name, begin, end, visibleOnTimeline }` 형태로 맞춘다.
*/
_migrateClipShape() {
for (let i = 0; i < this._clips.length; i++) {
const c = this._clips[i];
if (c.begin === undefined && c.startTime !== undefined) {
c.begin = Number(c.startTime);
c.end = Number(c.endTime);
}
if (c.name === undefined || String(c.name).trim() === '') c.name = `편집 구간 ${i + 1}`;
if (c.visibleOnTimeline === undefined) c.visibleOnTimeline = true;
if (Number.isFinite(Number(c.begin))) c.begin = this._roundClipSec(Number(c.begin));
if (Number.isFinite(Number(c.end))) c.end = this._roundClipSec(Number(c.end));
}
}
// 새 편집 구간을 추가한다 (배열 끝 = 리스트 순서; 시간순 자동 정렬 없음). name 생략·빈 문자열이면 `편집 구간 n`.
_clipAdd(begin, end, name = undefined) {
let b = Math.min(Number(begin), Number(end));
let e = Math.max(Number(begin), Number(end));
if (!Number.isFinite(b)) b = 0;
if (!Number.isFinite(e)) e = b;
b = this._roundClipSec(b);
e = this._roundClipSec(e);
const n = this._clips.length + 1;
const label = name != null && String(name).trim() !== '' ? String(name).trim() : `편집 구간 ${n}`;
this._clips.push({ name: label, begin: b, end: e, visibleOnTimeline: true });
}
// 지정 인덱스 편집 구간 필드를 갱신한다 (표·타임라인 조작 후).
_clipUpdate(clipIdx, patch) {
const c = this._clips[clipIdx];
if (!c) return;
if (patch.begin !== undefined) {
const v = Number(patch.begin);
if (Number.isFinite(v)) c.begin = this._roundClipSec(v);
}
if (patch.end !== undefined) {
const v = Number(patch.end);
if (Number.isFinite(v)) c.end = this._roundClipSec(v);
}
if (patch.name !== undefined) c.name = String(patch.name);
if (patch.visibleOnTimeline !== undefined) c.visibleOnTimeline = !!patch.visibleOnTimeline;
}
// 한 편집 구간을 배열에서 제거한다 (표의 삭제 버튼).
_clipRemove(clipIdx) {
if (this._isClipListBusy()) return;
if (clipIdx < 0 || clipIdx >= this._clips.length) return;
this._clips.splice(clipIdx, 1);
}
// 모든 편집 구간을 비운다 (전체 비우기 버튼).
_clipClearAll() {
if (this._isClipListBusy()) return;
if (this._clips.length > 0) this._recordClipUndo();
this._clips = [];
}
// 편집 구간들의 (끝−시작) 합을 초 단위로 구한다 (미리보기 라벨·검증 보조).
_clipTotalDurationSec() {
let sum = 0;
for (const c of this._clips) {
sum += Math.max(0, c.end - c.begin);
}
return sum;
}
// 합계 초를 패널 라벨용으로 붙인다 — 60초 미만은 초만, 이상은 분·초(소수 둘째).
_formatClipTotalSumLabel(sumSec) {
if (!Number.isFinite(sumSec) || sumSec < 0) return '총 길이 0.00초';
const m = Math.floor(sumSec / 60);
const sRem = Math.max(0, sumSec - m * 60);
if (m === 0) return `총 길이 ${sRem.toFixed(2)}초`;
return `총 길이 ${m}분 ${sRem.toFixed(2)}초`;
}
/** 게시 API와 동일 규칙(시각 소수 둘째 자리)으로 한 편집 구간의 길이(초). 비정상이면 null. */
_clipPublishDurationSec(c) {
const startTime = this._roundClipSec(c.begin);
const endTime = this._roundClipSec(c.end);
if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) return null;
const duration = this._roundClipSec(endTime - startTime);
if (!Number.isFinite(duration)) return null;
return Math.max(0, duration);
}
// 편집 구간이 유효한지 검사한다 (게시 저장 직전·필요 시 호출).
_clipValidate() {
for (let i = 0; i < this._clips.length; i++) {
const c = this._clips[i];
if (!(c.end >= c.begin)) {
return { ok: false, message: `편집 구간 ${i + 1}: 끝 시각이 시작보다 작을 수 없습니다.` };
}
if (c.begin < 0) {
return { ok: false, message: `편집 구간 ${i + 1}: 시작 시각이 음수입니다.` };
}
}
return { ok: true };
}
/**
* 타임라인·시크 클램프에 쓰는 총 길이. vodCore 메타 → `tsManager.getTotalFileDurationSec`(SOOP API)·`<video>.duration` 순.
*/
_getTotalDurationSec() {
const vc = this._getVodCore();
let meta = 0;
if (vc && vc.config && typeof vc.config === 'object') {
const pick = (key) => {
const raw = vc.config[key];
const x = parseFloat(raw == null ? '' : String(raw));
return Number.isFinite(x) && x > 0 ? x : 0;
};
const cfs = pick('configFilesDurationSum');
const sm = pick('fileItemsDurationSum');
const tf = pick('totalFileDuration');
const vals = [cfs, sm, tf].filter((v) => v > 0);
if (vals.length === 1) meta = vals[0];
else if (vals.length >= 2) {
const lo = Math.min(...vals);
const hi = Math.max(...vals);
meta = hi > lo + 0.5 && lo < hi * 0.5 ? hi : lo;
}
}
if (meta > 0) return meta;
const ts = window.VODSync?.tsManager;
if (ts && typeof ts.getTotalFileDurationSec === 'function') {
const apiSec = ts.getTotalFileDurationSec();
if (apiSec !== null && Number.isFinite(apiSec) && apiSec > 0) return apiSec;
}
const v = this._getVideo();
const vd =
v && Number.isFinite(v.duration) && v.duration > 0 && v.duration !== Number.POSITIVE_INFINITY
? v.duration
: 0;
if (vd > 0) return vd;
return 3600;
}
/**
* `tsManager.getCurPlaybackTime()` → 실패 시 `<video>.currentTime`.
* 동일 동기 스택에서 여러 번 호출돼도 실제 DOM 읽기는 한 번만 한다.
*/
_refreshCachedGlobalPlaybackTime() {
if (this._gpGlobalPlaybackReadCoalesced) return;
this._gpGlobalPlaybackReadCoalesced = true;
try {
const ts = window.VODSync?.tsManager;
if (ts && typeof ts.getCurPlaybackTime === 'function') {
const pt = ts.getCurPlaybackTime();
if (pt !== null && Number.isFinite(pt)) {
this._cachedGlobalPlaybackSec = Math.max(0, pt);
return;
}
}
const v = this._getVideo();
this._cachedGlobalPlaybackSec = v && Number.isFinite(v.currentTime) ? Math.max(0, v.currentTime) : 0;
} finally {
queueMicrotask(() => {
this._gpGlobalPlaybackReadCoalesced = false;
});
}
}
_resetPlayheadExtrap() {
this._phExRaw = null;
this._phExAnchorSec = 0;
this._phExWallMs = 0;
}
/**
* 노란 헤드 **표시**용 시각 (rAF). vodCore `playingTime` 있으면 보간, 없으면 `tsManager.getCurPlaybackTime`·`<video>`.
*/
_computePlayheadDisplaySec(total) {
const vc = this._getVodCore();
const rawPlayback = vc?.playerController?.playingTime;
const playbackSec =
typeof rawPlayback === 'number' && Number.isFinite(rawPlayback) ? Math.max(0, rawPlayback) : null;
const v = this._getVideo();
const haveCur =
typeof HTMLMediaElement !== 'undefined'
? HTMLMediaElement.HAVE_CURRENT_DATA
: /* @__PURE__ */ 2;
const playing = Boolean(v && !v.paused && !v.ended && v.readyState >= haveCur);
if (playbackSec != null) {
if (!playing) {
this._resetPlayheadExtrap();
return Math.min(total, playbackSec);
}
const now = performance.now();
const eps = 1e-4;
if (this._phExRaw == null || Math.abs(playbackSec - this._phExRaw) >= eps) {
this._phExRaw = playbackSec;
this._phExAnchorSec = playbackSec;
this._phExWallMs = now;
return Math.max(0, Math.min(total, playbackSec));
}
const elapsedSec = (now - this._phExWallMs) / 1000;
if (elapsedSec > 0.35) {
this._phExAnchorSec = playbackSec;
this._phExWallMs = now;
return Math.max(0, Math.min(total, playbackSec));
}
const rate = v.playbackRate || 1;
return Math.max(0, Math.min(total, this._phExAnchorSec + elapsedSec * rate));
}
this._resetPlayheadExtrap();
const ts = window.VODSync?.tsManager;
if (ts && typeof ts.getCurPlaybackTime === 'function') {
const pt = ts.getCurPlaybackTime();
if (pt !== null && Number.isFinite(pt)) {
return Math.min(total, Math.max(0, pt));
}
}
const ct = v && Number.isFinite(v.currentTime) ? Math.max(0, v.currentTime) : null;
if (ct != null) return Math.min(total, ct);
return 0;
}
// `tsManager.moveToPlaybackTime`(URL·vodCore·time_link) → ts 없을 때만 vodCore·`<video>`.
_plSeekGlobal(globalSec) {
const s = Number(globalSec);
const sec = Number.isFinite(s) ? Math.max(0, s) : 0;
const ts = window.VODSync?.tsManager;
if (ts && typeof ts.moveToPlaybackTime === 'function') {
ts.moveToPlaybackTime(sec, false);
return;
}
const vc = this._getVodCore();
if (vc && typeof vc.seek === 'function') {
try {
vc.seek(sec);
return;
} catch (e) {
/* ignore */
}
}
const v = this._getVideo();
if (v) {
try {
v.currentTime = sec;
} catch (e) {
/* ignore */
}
}
}
// 문서의 첫 `<video>` 요소 (vodCore 미가동·보조 시 길이·재생 시각).
_getVideo() {
const v = document.querySelector('video');
return v instanceof HTMLVideoElement ? v : null;
}
// vodCore 어댑터로 배속 적용(확장=브리지 속성, TM=unsafeWindow.vodCore.speed); 실패 시 `<video>.playbackRate`.
_setPlaybackSpeedFromUi(rate) {
const r = Number(rate);
if (!Number.isFinite(r) || r <= 0) return;
const vc = this._getVodCore();
if (vc) {
try {
vc.speed = r;
return;
} catch (_) {
/* ignore */
}
}
const v = this._getVideo();
if (v) {
try {
v.playbackRate = r;
} catch (e) {
/* ignore */
}
}
}
// 플레이어 `<video>.playbackRate` 를 기준으로 배속 드롭다운 표시를 가장 가까운 옵션에 맞춘다 (패널 표시 시).
_syncPlaybackSpeedSelectFromPlayer() {
const sel = this._playbackSpeedSelect;
if (!sel) return;
const opts = SoopVeditorReplacement.PLAYBACK_SPEED_OPTIONS;
let cur = 1;
const v = this._getVideo();
if (v && Number.isFinite(v.playbackRate) && v.playbackRate > 0) cur = v.playbackRate;
let best = opts[3];
let bestDiff = Math.abs(best - cur);
for (let i = 0; i < opts.length; i++) {
const d = Math.abs(opts[i] - cur);
if (d < bestDiff) {
bestDiff = d;
best = opts[i];
}
}
sel.value = String(best);
}
// 뷰포트 너비에 맞춰 전체 타임라인이 한 화면에 들어가게 하는 최소 px/초를 구한다 (줌 하한).
_minPpsToFitViewport(totalSec) {
const vp = this._timelineViewport;
const total = totalSec !== undefined ? totalSec : this._getTotalDurationSec();
if (!vp || total <= 0) return SoopVeditorReplacement.MIN_PPS_ABS_FLOOR;
const w = Math.max(vp.clientWidth, 1);
return Math.max(SoopVeditorReplacement.MIN_PPS_ABS_FLOOR, w / total);
}
/**
* 타임라인 inner 너비(px). `total*pps`가 뷰포트보다 작으면 inner가 viewport보다 좁아져 옆에 빈 틈이 보이므로
* 항상 최소 `clientWidth` 이상으로 맞춘다 (눈금·편집 구간 좌표는 여전히 `t*pps` 기준).
*/
_getTimelineInnerWidthPx(total, pps) {
const vp = this._timelineViewport;
const raw = total > 0 ? total * pps : 0;
if (!vp) return Math.max(1, raw);
const cw = Math.max(vp.clientWidth, 1);
return Math.max(raw, cw);
}
// 픽셀/초 줌 값을 허용 범위와 뷰포트 맞춤 하한 사이로 잘라낸다 (휠 줌·동기화 시).
_clampPps(pps, totalSec) {
const minPps = this._minPpsToFitViewport(totalSec);
const lo = Math.max(SoopVeditorReplacement.MIN_PPS_ABS_FLOOR, minPps);
return Math.max(lo, Math.min(SoopVeditorReplacement.MAX_PPS, pps));
}
// 타임라인 내부 너비가 바뀐 뒤 가로 스크롤이 범위를 벗어나지 않게 맞춘다 (줌·리사이즈 후).
_clampViewportScroll(innerW) {
const vp = this._timelineViewport;
if (!vp) return;
const maxScroll = Math.max(0, innerW - vp.clientWidth);
if (maxScroll <= 0) {
vp.scrollLeft = 0;
} else {
vp.scrollLeft = Math.max(0, Math.min(vp.scrollLeft, maxScroll));
}
this._updateTimelineScrollBarUI();
}
// 오버레이용 기본 스타일 버튼을 만들고 클릭 후 포커스를 뺀다 (접근성·키보드 트랩 완화).
_btn(label, onClick) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'vs-veditor-btn';
b.textContent = label;
b.addEventListener('click', onClick);
b.addEventListener('click', () => {
queueMicrotask(() => b.blur());
});
return b;
}
_getSoopApi() {
return window.VODSync?.soopAPI ?? null;
}
_setPublishError(msg) {
if (!this._publishErrEl) return;
this._publishErrEl.textContent = msg || '';
}
_setPublishSubmitting(on) {
this._publishSubmitting = !!on;
if (this._publishSubmitBtn) this._publishSubmitBtn.disabled = !!on;
if (this._publishBoardSel) this._publishBoardSel.disabled = !!on;
if (this._publishVodCategorySel) this._publishVodCategorySel.disabled = !!on;
if (this._publishVodCategorySubSel) this._publishVodCategorySubSel.disabled = !!on;
if (this._publishLangSel) this._publishLangSel.disabled = !!on;
if (this._publishTitleInp) this._publishTitleInp.disabled = !!on;
if (this._publishContentsInp) this._publishContentsInp.disabled = !!on;
}
_toggleClipPanelCollapsed() {
this._clipPanelCollapsed = !this._clipPanelCollapsed;
this._updateClipPanelCollapseUi();
}
_updateClipPanelCollapseUi() {
if (this._clipPanelEl) this._clipPanelEl.classList.toggle('vs-collapsed', this._clipPanelCollapsed);
if (this._clipPanelToggleBtn) {
this._clipPanelToggleBtn.textContent = this._clipPanelCollapsed ? '펼치기' : '접기';
this._clipPanelToggleBtn.setAttribute('aria-label', this._clipPanelCollapsed ? '편집 구간 목록 펼치기' : '편집 구간 목록 접기');
this._clipPanelToggleBtn.title = this._clipPanelCollapsed ? '편집 구간 목록 펼치기' : '편집 구간 목록 접기';
}
}
_setSelectOptions(selectEl, list, placeholder, valueKey, labelKey) {
if (!selectEl) return;
selectEl.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = placeholder;
selectEl.appendChild(ph);
for (const it of list) {
const op = document.createElement('option');
op.value = String(it[valueKey] ?? '');
op.textContent = String(it[labelKey] ?? it[valueKey] ?? '');
if (it.category !== undefined) op.dataset.category = String(it.category);
selectEl.appendChild(op);
}
}
_collectVodCategoryTree(catRoot) {
const out = [];
const roots = catRoot?.CHANNEL?.VOD_CATEGORY;
if (!Array.isArray(roots)) return out;
for (const major of roots) {
const majorName = major?.cate_name;
const majorVodCategory = major?.cate_no || major?.vod_category;
if (!majorName || !majorVodCategory) continue;
const majorCategory = String(major?.ucc_cate || '00210000');
const node = {
name: String(majorName),
vodCategory: String(majorVodCategory),
category: majorCategory,
children: [],
};
const children = Array.isArray(major?.child) ? major.child : [];
for (const child of children) {
const childName = child?.cate_name;
const childVodCategory = child?.cate_no || child?.vod_category;
if (!childName || !childVodCategory) continue;
node.children.push({
name: String(childName),
vodCategory: String(childVodCategory),
category: String(child?.ucc_cate || majorCategory),
});
}
out.push(node);
}
return out;
}
_fillVodCategorySubOptionsByMain(mainVodCategory) {
const sel = this._publishVodCategorySubSel;
if (!sel) return;
const main = this._publishVodCategoryTree.find((x) => x.vodCategory === String(mainVodCategory));
const children = main?.children || [];
if (children.length === 0) {
this._setSelectOptions(sel, [], '세부 카테고리 없음', 'vodCategory', 'name');
sel.disabled = true;
return;
}
this._setSelectOptions(sel, children, '카테고리 선택', 'vodCategory', 'name');
sel.disabled = false;
}
_getPublishVodCategorySelection() {
const mainValue = this._publishVodCategorySel?.value || '';
if (!mainValue) return null;
const main = this._publishVodCategoryTree.find((x) => x.vodCategory === String(mainValue));
if (!main) return null;
const subValue = this._publishVodCategorySubSel?.value || '';
if (subValue && Array.isArray(main.children) && main.children.length > 0) {
const sub = main.children.find((x) => x.vodCategory === String(subValue));
if (sub) return sub;
}
return main;
}
_buildPublishEditJobInfo() {
const block = [];
for (let i = 0; i < this._clips.length; i++) {
const c = this._clips[i];
const startTime = this._roundClipSec(c.begin);
const endTime = this._roundClipSec(c.end);
const duration = this._roundClipSec(endTime - startTime);
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || !Number.isFinite(duration) || duration <= 0) {
return null;
}
block.push({
startTime,
endTime,
duration,
idx: i,
sectionIdx: 0,
});
}
return [block];
}
async _resolvePublishLoginId() {
const vc = this._getVodCore();
const fromVodCore = vc?.config?.loginId != null ? String(vc.config.loginId) : '';
if (fromVodCore) return String(fromVodCore);
const api = this._getSoopApi();
if (!api || typeof api.GetPrivateInfo !== 'function') return null;
const priv = await api.GetPrivateInfo();
return priv?.CHANNEL?.LOGIN_ID ?? null;
}
// --- 게시 (모달, API) ---
async _onPublishButtonClick() {
if (this._isClipListBusy() || this._publishSubmitting) return;
const failOpen = (msg) => {
this._setPublishError(msg);
alert(msg);
};
if (this._clips.length === 0) {
alert('게시할 편집 구간이 없습니다.');
return;
}
const clipVal = this._clipValidate();
if (!clipVal.ok) {
alert(clipVal.message);
return;
}
let anySegmentLt3 = false;
for (const c of this._clips) {
const d = this._clipPublishDurationSec(c);
if (d === null) {
alert('편집 구간 시각을 확인할 수 없습니다.');
return;
}
if (d < 3) anySegmentLt3 = true;
}
const totalClipSec = this._clipTotalDurationSec();
if (totalClipSec < 15) {
alert('편집 구간 길이 총합이 15초 이상이어야 합니다.');
return;
}
if (totalClipSec > 1800) {
alert('편집 구간 길이 총합이 30분(1800초) 이하여야 합니다.');
return;
}
if (anySegmentLt3) {
alert('3초 미만인 편집 구간이 있습니다. 이는 최종 결과물에 포함되지 않을 것입니다.');
}
const api = this._getSoopApi();
if (!api) {
alert('soopAPI를 찾을 수 없습니다.');
return;
}
this._setPublishError('');
this._setPublishSubmitting(true);
try {
const titleNo = String(this.titleNo || '');
if (!titleNo) {
failOpen('titleNo를 확인할 수 없습니다.');
return;
}
const [webInfo, catTree, loginId] = await Promise.all([
api.GetSoopVeditorWebVodInfo?.(titleNo),
api.GetVodEditorCategory?.(),
this._resolvePublishLoginId(),
]);
if (!loginId) {
failOpen('로그인 ID를 확인할 수 없습니다.');
return;
}
const menu = await api.GetStationMenu?.(loginId);
const boards = Array.isArray(menu?.board) ? menu.board.filter((b) => Number(b?.displayType) === 104) : [];
const langsObj = webInfo?.response?.info?.langs || {};
const langs = Object.keys(langsObj).map((k) => ({ code: k, name: langsObj[k] }));
const cats = this._collectVodCategoryTree(catTree);
if (boards.length === 0 || langs.length === 0 || cats.length === 0) {
failOpen('게시판/카테고리/언어 목록 조회에 실패했습니다.');
return;
}
this._setSelectOptions(this._publishBoardSel, boards, '게시판 선택', 'bbsNo', 'name');
this._publishVodCategoryTree = cats;
this._setSelectOptions(this._publishVodCategorySel, cats, '대분류 선택', 'vodCategory', 'name');
this._fillVodCategorySubOptionsByMain('');
this._setSelectOptions(this._publishLangSel, langs, '언어 선택', 'code', 'name');
if (this._publishLangSel) this._publishLangSel.value = 'ko_KR';
if (this._publishTitleInp) this._publishTitleInp.value = '';
if (this._publishContentsInp) {
const vodOrigin = window.VODSync?.SoopUrls?.VOD_ORIGIN || 'https://vod.sooplive.com';
const reviewUrl = new URL(`${vodOrigin}/player/${titleNo}`);
const firstClip = this._clips[0];
if (firstClip) {
const sec = this._roundClipSec(Number(firstClip.begin));
if (Number.isFinite(sec) && sec >= 0) {
reviewUrl.searchParams.set('change_second', String(Math.round(sec)));
}
}
const head = `원본 다시보기: ${reviewUrl.toString()}`;
const clipBlock = this._clips
.map((c, i) => {
const b = this._formatClipTimeInput(c.begin);
const e = this._formatClipTimeInput(c.end);
const nm = String(c.name || '').trim() || `편집 구간 ${i + 1}`;
return `${i + 1}. ${nm}: ${b} ~ ${e}`;
})
.join('\n');
this._publishContentsInp.value = `${head}\n${clipBlock}`;
}
if (this._publishModalEl) this._publishModalEl.classList.add('vs-open');
} catch (e) {
this.error('게시 모달 데이터 로딩 실패', e);
failOpen('게시 모달을 준비하지 못했습니다.');
} finally {
this._setPublishSubmitting(false);
}
}
_closePublishModal() {
if (this._publishModalEl) this._publishModalEl.classList.remove('vs-open');
this._setPublishError('');
}
async _submitPublishModal() {
if (this._publishSubmitting) return;
const board = this._publishBoardSel?.value || '';
const selectedCategory = this._getPublishVodCategorySelection();
const lang = (this._publishLangSel?.value || '').trim();
const title = (this._publishTitleInp?.value || '').trim();
const contents = (this._publishContentsInp?.value || '').trim();
if (!board || !selectedCategory?.vodCategory || !lang || !title) {
this._setPublishError('게시판, VOD 카테고리, 언어, 제목은 필수입니다.');
return;
}
const clipVal = this._clipValidate();
if (!clipVal.ok) {
this._setPublishError(clipVal.message);
return;
}
const editJobInfo = this._buildPublishEditJobInfo();
if (!editJobInfo) {
this._setPublishError('편집 구간 정보 변환에 실패했습니다.');
return;
}
const api = this._getSoopApi();
if (!api || typeof api.SetWebEditorJob !== 'function') {
this._setPublishError('게시 API를 찾을 수 없습니다.');
return;
}
const titleNo = String(this.titleNo || '');
if (!titleNo) {
this._setPublishError('titleNo를 확인할 수 없습니다.');
return;
}
this._setPublishSubmitting(true);
this._setPublishError('');
try {
const webInfo = await api.GetSoopVeditorWebVodInfo?.(titleNo);
const broadNo = webInfo?.response?.info?.broad_no;
if (!broadNo) {
this._setPublishError('broadNo를 확인할 수 없습니다.');
return;
}
const res = await api.SetWebEditorJob({
titleNo,
broadNo: String(broadNo),
bbsNo: String(board),
category: String(selectedCategory.category || '00210000'),
vodCategory: String(selectedCategory.vodCategory),
title,
contents,
strmLangType: String(lang),
editType: '1',
editJobInfo,
});
if (!res) {
this._setPublishError('게시 요청에 실패했습니다.');
return;
}
this._closePublishModal();
alert(res.MSG || '게시 요청을 전송했습니다.');
} catch (e) {
this.error('게시 API 요청 실패', e);
this._setPublishError('게시 요청 중 오류가 발생했습니다.');
} finally {
this._setPublishSubmitting(false);
}
}
// 편집 패널 DOM·CSS·타임라인·편집 구간 표를 생성해 body에 붙이고 이벤트를 연결한다 (최초 오픈 시 한 번).
_mountOverlayDom() {
const shell = document.createElement('div');
shell.id = 'vod-sync-veditor-overlay';
shell.className = 'vs-veditor-overlay';
const wrap = document.createElement('div');
wrap.id = 'vod-sync-veditor-root';
wrap.className = 'vs-veditor-root vs-veditor-root--overlay';
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-label', '편집 VOD 만들기');
const style = document.createElement('style');
style.textContent = SoopVeditorReplacement.OverlayInlineStyles.cssText();
wrap.appendChild(style);
this._veditorMountOverlayPanelElements(wrap);
shell.appendChild(wrap);
document.body.appendChild(shell);
this._overlayShell = shell;
this.rootEl = wrap;
this._veditorBindOverlayControls();
}
/**
* 오버레이 패널 DOM 트리(타임라인·편집 구간 목록·게시 모달)만 조립한다. 스타일은 `SoopVeditorReplacement.OverlayInlineStyles`.
* @param {HTMLDivElement} wrap
*/
_veditorMountOverlayPanelElements(wrap) {
const titleRow = document.createElement('div');
titleRow.className = 'vs-veditor-title-row';
const titleHead = document.createElement('div');
titleHead.className = 'vs-veditor-title-head';
const title = document.createElement('div');
title.className = 'vs-veditor-title';
title.textContent = 'VOD 편집하기';
const closeBtn = this._btn('닫기', () => this._hidePanel());
this._officialVeditorBtn = this._btn('공식 편집기 열기', () => {
const id = String(this.titleNo || '').trim();
if (!id) return;
const url = `https://veditor.sooplive.com/web/${encodeURIComponent(id)}`;
window.open(url, '_blank', 'noopener,noreferrer');
});
this._officialVeditorBtn.title = 'SOOP 공식 웹 편집기(새 탭)';
this._officialVeditorBtn.setAttribute('aria-label', 'SOOP 공식 웹 편집기 새 탭');
titleHead.appendChild(title);
titleHead.appendChild(this._officialVeditorBtn);
titleRow.appendChild(titleHead);
this._timelineViewport = document.createElement('div');
this._timelineViewport.className = 'vs-veditor-timeline-viewport';
this._timelineInner = document.createElement('div');
this._timelineInner.className = 'vs-veditor-timeline-inner';
this._rulerEl = document.createElement('div');
this._rulerEl.className = 'vs-veditor-ruler';
this._trackEl = document.createElement('div');
this._trackEl.className = 'vs-veditor-track';
this._timelineInner.appendChild(this._rulerEl);
this._timelineInner.appendChild(this._trackEl);
this._playheadEl = document.createElement('div');
this._playheadEl.className = 'vs-veditor-playhead';
this._playheadEl.setAttribute('role', 'slider');
this._playheadEl.setAttribute('aria-label', '재생 헤드');
const phLine = document.createElement('div');
phLine.className = 'vs-veditor-playhead-line';
const phHead = document.createElement('div');
phHead.className = 'vs-veditor-playhead-head';
this._playheadEl.appendChild(phLine);
this._playheadEl.appendChild(phHead);
this._timelineInner.appendChild(this._playheadEl);
this._timelineViewport.appendChild(this._timelineInner);
this._timelineScrollBarEl = document.createElement('div');
this._timelineScrollBarEl.className = 'vs-veditor-timeline-scroll-wrap';
this._timelineScrollTrackEl = document.createElement('div');
this._timelineScrollTrackEl.className = 'vs-veditor-timeline-scroll-track';
this._timelineScrollThumbEl = document.createElement('div');
this._timelineScrollThumbEl.className = 'vs-veditor-timeline-scroll-thumb';
this._timelineScrollThumbEl.setAttribute('role', 'slider');
this._timelineScrollThumbEl.setAttribute('aria-label', '타임라인 가로 스크롤');
this._timelineScrollTrackEl.appendChild(this._timelineScrollThumbEl);
this._timelineScrollBarEl.appendChild(this._timelineScrollTrackEl);
const timelineDock = document.createElement('div');
timelineDock.className = 'vs-veditor-timeline-dock';
const seqHeader = document.createElement('div');
seqHeader.className = 'vs-veditor-seq-header';
this._timecodeEl = document.createElement('span');
this._timecodeEl.className = 'vs-veditor-timecode';
this._timecodeEl.textContent = '00:00:00.000';
const toolModes = document.createElement('div');
toolModes.className = 'vs-veditor-timeline-tools';
this._timelineToolSelectBtn = this._btn('', () => this._setTimelineToolMode('select'));
this._timelineToolSelectBtn.classList.add('vs-veditor-tool-btn');
this._timelineToolSelectBtn.innerHTML = SoopVeditorReplacement.TIMELINE_TOOL_SVG_SELECT;
this._timelineToolSelectBtn.title = '선택 모드 (V)';
this._timelineToolSelectBtn.setAttribute('aria-label', '선택 모드');
this._timelineToolCutBtn = this._btn('', () => this._setTimelineToolMode('cut'));
this._timelineToolCutBtn.classList.add('vs-veditor-tool-btn');
this._timelineToolCutBtn.innerHTML = SoopVeditorReplacement.TIMELINE_TOOL_SVG_CUT;
this._timelineToolCutBtn.title = '자르기 모드 (C)';
this._timelineToolCutBtn.setAttribute('aria-label', '자르기 모드');
this._timelineLabelModeSelect = document.createElement('select');
this._timelineLabelModeSelect.className = 'vs-veditor-timeline-label-mode';
this._timelineLabelModeSelect.title = '타임라인 라벨 표시';
this._timelineLabelModeSelect.setAttribute('aria-label', '타임라인 라벨 표시');
this._timelineLabelModeSelect.innerHTML =
'<option value="index">편집 구간 표시: 순서</option><option value="name">편집 구간 표시: 이름</option>';
this._timelineLabelModeSelect.value = this._timelineClipLabelMode;
this._timelineLabelModeSelect.addEventListener('change', () => {
this._timelineClipLabelMode =
this._timelineLabelModeSelect && this._timelineLabelModeSelect.value === 'name' ? 'name' : 'index';
this._renderClipsOnTrack(this._getTotalDurationSec(), this._pixelsPerSecond);
});
toolModes.appendChild(this._timelineToolSelectBtn);
toolModes.appendChild(this._timelineToolCutBtn);
const seqHeadRight = document.createElement('div');
seqHeadRight.className = 'vs-veditor-seq-head-right';
seqHeadRight.appendChild(toolModes);
const titleActions = document.createElement('div');
titleActions.className = 'vs-veditor-title-actions';
titleActions.appendChild(this._timecodeEl);
titleActions.appendChild(this._timelineLabelModeSelect);
titleActions.appendChild(closeBtn);
titleRow.appendChild(titleActions);
const timelineGraphCol = document.createElement('div');
timelineGraphCol.className = 'vs-veditor-timeline-graph-col';
timelineGraphCol.appendChild(this._timelineViewport);
timelineGraphCol.appendChild(this._timelineScrollBarEl);
seqHeader.appendChild(seqHeadRight);
this._sequenceHeaderEl = seqHeader;
timelineDock.appendChild(seqHeader);
timelineDock.appendChild(timelineGraphCol);
const clipCol = document.createElement('div');
clipCol.className = 'vs-veditor-clip-col';
const clipToolbar = document.createElement('div');
clipToolbar.className = 'vs-veditor-clip-toolbar';
const tbr1 = document.createElement('div');
tbr1.className = 'vs-veditor-clip-toolbar-row vs-veditor-title-inline-actions';
this._clipToolbarAddBtn = this._btn('+', () => this._addDefaultClip());
this._clipToolbarAddBtn.classList.add('vs-veditor-btn-icon');
this._clipToolbarAddBtn.title = '편집 구간 추가';
this._clipToolbarAddBtn.setAttribute('aria-label', '편집 구간 추가');
this._clipToolbarStartBtn = this._btn('[', () => this._applyCurrentAsStart());
this._clipToolbarStartBtn.classList.add('vs-veditor-btn-icon');
this._clipToolbarStartBtn.title = '선택한 편집 구간의 시작을 현재 재생 위치로 맞춤 (단축키: [)';
this._clipToolbarStartBtn.setAttribute('aria-label', '선택한 편집 구간의 시작을 현재 재생 위치로 맞춤');
this._clipToolbarEndBtn = this._btn(']', () => this._applyCurrentAsEnd());
this._clipToolbarEndBtn.classList.add('vs-veditor-btn-icon');
this._clipToolbarEndBtn.title = '선택한 편집 구간의 끝을 현재 재생 위치로 맞춤 (단축키: ])';
this._clipToolbarEndBtn.setAttribute('aria-label', '선택한 편집 구간의 끝을 현재 재생 위치로 맞춤');
tbr1.appendChild(this._clipToolbarAddBtn);
tbr1.appendChild(this._clipToolbarStartBtn);
tbr1.appendChild(this._clipToolbarEndBtn);
this._clipToolbarFitBtn = this._btn('[<>]', () => {
const clips = this._getClips();
const i = this._selectedClipIndex;
if (!clips[i]) return;
this._fitTimelineToClipIndex(i);
});
this._clipToolbarFitBtn.classList.add('vs-veditor-btn-icon');
this._clipToolbarFitBtn.title = '선택한 편집 구간에 타임라인 맞춤';
this._clipToolbarFitBtn.setAttribute('aria-label', '선택한 편집 구간에 타임라인 맞춤');
this._clipToolbarPlaySelBtn = this._btn('편집 구간 재생', () => {
if (this._playAllMode) return;
const clips = this._getClips();
const i = this._selectedClipIndex;
const c = clips[i];
if (!c) return;
if (this._playSingleClipMode) {
this._stopPlayAll(true);
return;
}
this._stopPlayAll(false);
this._playSingleClipMode = true;
this._playbackClipEntered = false;
this._playSingleClipBeginSec = c.begin;
this._playSingleClipEndSec = c.end;
this._playAllSeekGraceUntil = performance.now() + 200;
this._plSeekGlobal(c.begin);
const v = this._getVideo();
if (v) v.play().catch(() => {});
this._syncUiFromState();
this._updateClipToolbarSelectionActions();
const tick = () => {
if (!this._playSingleClipMode || !this._panelVisible) return;
if (this._playAllSeekGraceUntil && performance.now() < this._playAllSeekGraceUntil) {
this._playAllRaf = requestAnimationFrame(tick);
return;
}
this._playAllSeekGraceUntil = 0;
this._refreshCachedGlobalPlaybackTime();
const t = this._cachedGlobalPlaybackSec;
const begin = this._playSingleClipBeginSec;
const end = this._playSingleClipEndSec;
if (SoopVeditorReplacement.ClipBoundaryPlayback.advance(this, t, begin, end) === 'segment_end') {
this._stopPlayAll(true);
return;
}
this._playAllRaf = requestAnimationFrame(tick);
};
this._playAllRaf = requestAnimationFrame(tick);
});
this._clipToolbarDupBtn = this._btn('복제', () => {
if (!this._getClips()[this._selectedClipIndex]) return;
this._duplicateSelectedClip();
});
this._clipToolbarDupBtn.title = '선택한 편집 구간 복제';
this._clipToolbarDupBtn.setAttribute('aria-label', '선택한 편집 구간 복제');
this._clipToolbarDelBtn = this._btn('삭제', () => {
const clips = this._getClips();
const i = this._selectedClipIndex;
if (!clips[i]) return;
this._recordClipUndo();
this._clipRemove(i);
this._ensureSelectedClipIndex();
this._syncUiFromState();
});
this._clipToolbarDelBtn.title = '선택한 편집 구간 삭제';
this._clipToolbarDelBtn.setAttribute('aria-label', '선택한 편집 구간 삭제');
tbr1.appendChild(this._clipToolbarFitBtn);
tbr1.appendChild(this._clipToolbarPlaySelBtn);
tbr1.appendChild(this._clipToolbarDupBtn);
tbr1.appendChild(this._clipToolbarDelBtn);
this._playbackSpeedSelect = document.createElement('select');
this._playbackSpeedSelect.className = 'vs-veditor-playback-speed';
this._playbackSpeedSelect.title = '재생 배속';
this._playbackSpeedSelect.setAttribute('aria-label', '재생 배속');
for (const sp of SoopVeditorReplacement.PLAYBACK_SPEED_OPTIONS) {
const op = document.createElement('option');
op.value = String(sp);
op.textContent = `${sp}x`;
this._playbackSpeedSelect.appendChild(op);
}
this._playbackSpeedSelect.value = '1';
this._playbackSpeedSelect.addEventListener('change', () => {
const r = parseFloat(this._playbackSpeedSelect?.value || '1');
this._setPlaybackSpeedFromUi(r);
this._playbackSpeedSelect?.blur();
});
tbr1.appendChild(this._playbackSpeedSelect);
titleRow.insertBefore(tbr1, titleActions);
const tbr2 = document.createElement('div');
tbr2.className = 'vs-veditor-clip-toolbar-row';
this._clipToolbarTestBtn = this._btn('테스트 시작', () => {
if (this._playAllMode) {
this._stopPlayAll();
} else {
this._playAllClips();
}
});
this._clipToolbarTestBtn.classList.add('vs-veditor-btn-danger');
tbr2.appendChild(this._clipToolbarTestBtn);
this._clipToolbarPublishBtn = this._btn('게시하기', () => this._onPublishButtonClick());
this._clipToolbarPublishBtn.classList.add('vs-veditor-btn-primary');
this._clipPlayStatusEl = document.createElement('span');
this._clipPlayStatusEl.className = 'vs-veditor-clip-play-status';
this._clipPlayStatusEl.textContent = '';
this._clipPlayStatusEl.style.display = 'none';
tbr2.appendChild(this._clipPlayStatusEl);
this._clipTotalEl = document.createElement('span');
this._clipTotalEl.className = 'vs-veditor-clip-total';
this._clipTotalEl.textContent = this._formatClipTotalSumLabel(0);
tbr2.appendChild(this._clipTotalEl);
tbr2.appendChild(this._clipToolbarPublishBtn);
clipToolbar.appendChild(tbr2);
const clipScroll = document.createElement('div');
clipScroll.className = 'vs-veditor-clip-scroll';
this._clipListScrollEl = clipScroll;
this._bindClipListScrollDelegationOnce();
clipCol.appendChild(clipToolbar);
clipCol.appendChild(clipScroll);
const seqCol = document.createElement('div');
seqCol.className = 'vs-veditor-seq-col';
seqCol.appendChild(timelineDock);
const timelinePanel = document.createElement('div');
timelinePanel.className = 'vs-veditor-overlay-panel vs-veditor-timeline-panel';
timelinePanel.setAttribute('role', 'region');
timelinePanel.setAttribute('aria-label', '타임라인');
timelinePanel.appendChild(titleRow);
timelinePanel.appendChild(seqCol);
const clipPanelHead = document.createElement('div');
clipPanelHead.className = 'vs-veditor-clip-panel-head';
const clipPanelTitle = document.createElement('span');
clipPanelTitle.textContent = '편집 구간 리스트';
const clipPanelHeadActions = document.createElement('div');
clipPanelHeadActions.className = 'vs-veditor-clip-panel-head-actions';
this._timelineCopyActionSel = document.createElement('select');
this._timelineCopyActionSel.className = 'vs-veditor-playback-speed vs-veditor-timeline-copy-action';
this._timelineCopyActionSel.title = '모든 편집 구간을 댓글 타임라인 형식으로 복사 (옵션을 선택하면 즉시 복사됩니다.)';
this._timelineCopyActionSel.setAttribute('aria-label', '모든 편집 구간을 댓글 타임라인 형식으로 복사 (옵션을 선택하면 즉시 복사됩니다.)');
const promptVal = SoopVeditorReplacement.TIMELINE_COPY_PROMPT_VALUE;
this._timelineCopyActionSel.innerHTML =
`<option value="${promptVal}">타임라인 복사(선택하세요)</option>` +
'<option value="none">이름을 제외하여 복사</option>' +
'<option value="prefix">이름을 앞에 붙여 복사</option>' +
'<option value="suffix">이름을 뒤에 붙여 복사</option>';
this._timelineCopyActionSel.value = promptVal;
this._timelineCopyActionSel.addEventListener('change', async () => {
const sel = this._timelineCopyActionSel;
if (!sel) return;
if (sel.value === SoopVeditorReplacement.TIMELINE_COPY_PROMPT_VALUE) {
sel.blur();
return;
}
try {
await this._copyClipsAsTimelineComment();
} finally {
sel.value = SoopVeditorReplacement.TIMELINE_COPY_PROMPT_VALUE;
sel.blur();
}
});
clipPanelHeadActions.appendChild(this._timelineCopyActionSel);
this._clipPanelToggleBtn = this._btn('접기', () => this._toggleClipPanelCollapsed());
this._clipPanelToggleBtn.classList.add('vs-veditor-clip-panel-toggle');
clipPanelHeadActions.appendChild(this._clipPanelToggleBtn);
clipPanelHead.appendChild(clipPanelTitle);
clipPanelHead.appendChild(clipPanelHeadActions);
const clipPanel = document.createElement('div');
clipPanel.className = 'vs-veditor-overlay-panel vs-veditor-clip-panel';
clipPanel.setAttribute('role', 'region');
clipPanel.setAttribute('aria-label', '편집 구간 목록');
clipPanel.appendChild(clipPanelHead);
clipPanel.appendChild(clipCol);
this._clipPanelEl = clipPanel;
const dockRow = document.createElement('div');
dockRow.className = 'vs-veditor-dock-row';
dockRow.appendChild(timelinePanel);
dockRow.appendChild(clipPanel);
wrap.appendChild(dockRow);
this._rulerHoverTipEl = document.createElement('div');
this._rulerHoverTipEl.className = 'vs-veditor-ruler-hover-tip';
this._rulerHoverTipEl.setAttribute('aria-hidden', 'true');
document.body.appendChild(this._rulerHoverTipEl);
wrap.addEventListener('change', this._onClipListChange);
wrap.addEventListener(
'keydown',
(e) => {
if (e.key !== 'Enter') return;
const t = e.target;
if (!(t instanceof HTMLElement)) return;
if (t.closest('input, textarea, select, [contenteditable="true"]') == null) return;
if (!(t instanceof HTMLInputElement)) return;
if (!t.classList.contains('vs-veditor-clip-name') && !t.classList.contains('vs-veditor-clip-time-inp'))
return;
if (this._isClipListBusy()) return;
e.preventDefault();
e.stopPropagation();
t.blur();
},
true
);
const publishModal = document.createElement('div');
publishModal.className = 'vs-veditor-publish-modal';
const publishCard = document.createElement('div');
publishCard.className = 'vs-veditor-publish-card';
const publishHead = document.createElement('div');
publishHead.className = 'vs-veditor-publish-head';
const publishTitle = document.createElement('div');
publishTitle.textContent = '게시하기';
const publishClose = document.createElement('button');
publishClose.type = 'button';
publishClose.className = 'vs-veditor-publish-close';
publishClose.textContent = '×';
publishClose.addEventListener('click', () => this._closePublishModal());
publishHead.appendChild(publishTitle);
publishHead.appendChild(publishClose);
publishCard.appendChild(publishHead);
const grid = document.createElement('div');
grid.className = 'vs-veditor-publish-grid';
const row = (label, required, inputEl) => {
const box = document.createElement('div');
const lab = document.createElement('label');
lab.className = 'vs-veditor-publish-label';
lab.textContent = label;
if (required) {
const req = document.createElement('span');
req.className = 'vs-veditor-publish-required';
req.textContent = '*';
lab.appendChild(req);
}
box.appendChild(lab);
box.appendChild(inputEl);
return box;
};
this._publishBoardSel = document.createElement('select');
this._publishBoardSel.className = 'vs-veditor-publish-select';
this._publishVodCategorySel = document.createElement('select');
this._publishVodCategorySel.className = 'vs-veditor-publish-select';
this._publishVodCategorySubSel = document.createElement('select');
this._publishVodCategorySubSel.className = 'vs-veditor-publish-select';
this._publishVodCategorySel.addEventListener('change', () => {
this._fillVodCategorySubOptionsByMain(this._publishVodCategorySel?.value || '');
});
this._publishLangSel = document.createElement('select');
this._publishLangSel.className = 'vs-veditor-publish-select';
this._publishTitleInp = document.createElement('input');
this._publishTitleInp.type = 'text';
this._publishTitleInp.className = 'vs-veditor-publish-input vs-veditor-publish-title';
this._publishTitleInp.placeholder = '제목을 입력해주세요.';
this._publishContentsInp = document.createElement('textarea');
this._publishContentsInp.className = 'vs-veditor-publish-textarea';
this._publishContentsInp.placeholder = '내용을 입력해주세요.';
grid.appendChild(row('게시판 선택', true, this._publishBoardSel));
const catWrap = document.createElement('div');
catWrap.className = 'vs-veditor-publish-category-row';
catWrap.appendChild(this._publishVodCategorySel);
catWrap.appendChild(this._publishVodCategorySubSel);
grid.appendChild(row('VOD 카테고리 선택', true, catWrap));
grid.appendChild(row('언어 선택', true, this._publishLangSel));
grid.appendChild(row('제목', true, this._publishTitleInp));
grid.appendChild(row('내용', false, this._publishContentsInp));
publishCard.appendChild(grid);
const desc = document.createElement('div');
desc.className = 'vs-veditor-publish-desc';
desc.textContent =
'* 표시는 필수 입력입니다.\n* 본 영상에서 발생하는 별풍선 및 애드벌룬 수익은 방송한 스트리머에게 전달됩니다.';
publishCard.appendChild(desc);
this._publishErrEl = document.createElement('div');
this._publishErrEl.className = 'vs-veditor-publish-err';
publishCard.appendChild(this._publishErrEl);
const actions = document.createElement('div');
actions.className = 'vs-veditor-publish-actions';
const cancelBtn = this._btn('취소', () => this._closePublishModal());
cancelBtn.classList.add('vs-veditor-publish-cancel');
this._publishSubmitBtn = this._btn('저장', () => this._submitPublishModal());
this._publishSubmitBtn.classList.add('vs-veditor-publish-submit');
actions.appendChild(cancelBtn);
actions.appendChild(this._publishSubmitBtn);
publishCard.appendChild(actions);
publishModal.appendChild(publishCard);
publishModal.addEventListener('click', (e) => {
if (e.target === publishModal) this._closePublishModal();
});
wrap.appendChild(publishModal);
this._publishModalEl = publishModal;
}
/**
* 오버레이 마운트 직후 플레이헤드·타임라인 입력·리사이즈 등 컨트롤을 연결한다.
*/
_veditorBindOverlayControls() {
this._initPlayheadFromVideo();
this._bindPlayheadAndKeyboard();
this._bindTimelineWheel();
this._bindTimelineViewportScrollClamp();
this._bindTimelineCustomScrollbar();
this._bindViewportResize();
this._bindRulerHoverTimeTip();
this._ensureVideoPlayheadListeners();
this._updateClipPanelCollapseUi();
this._syncOfficialVeditorButtonState();
}
/** WheelEvent.deltaY 를 대략 픽셀 단위로 통일 (마우스/트랙패드·deltaMode 차이 완화). */
_wheelDeltaYPixels(ev, viewportEl) {
let dy = ev.deltaY;
if (ev.deltaMode === WheelEvent.DOM_DELTA_LINE) dy *= 16;
else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) dy *= Math.max(viewportEl.clientHeight, 1);
return dy;
}
// 타임라인 뷰포트에 휠 가로 스크롤(Alt 시 줌)을 붙이고 끝에 페인트를 요청한다.
_bindTimelineWheel() {
const vp = this._timelineViewport;
if (!vp) return;
vp.addEventListener(
'wheel',
(ev) => {
const total = this._getTotalDurationSec();
const rect = vp.getBoundingClientRect();
const relX = ev.clientX - rect.left;
ev.preventDefault();
if (!ev.altKey) {
vp.scrollLeft += this._wheelDeltaYPixels(ev, vp);
this._clampViewportScroll(
this._getTimelineInnerWidthPx(total, this._pixelsPerSecond)
);
this._requestTimelinePaint();
return;
}
const minPps = this._minPpsToFitViewport(total);
const pps = this._pixelsPerSecond;
const timeUnder = (vp.scrollLeft + relX) / pps;
const dy = this._wheelDeltaYPixels(ev, vp);
const zoomIn = dy < 0;
const k = SoopVeditorReplacement.ZOOM_WHEEL_EXP_PER_PX;
const factor = Math.exp(-dy * k);
let newPps = pps * factor;
newPps = this._clampPps(newPps, total);
if (zoomIn && newPps <= minPps * 1.000001) {
newPps = this._clampPps(minPps * 1.002, total);
}
this._pixelsPerSecond = newPps;
const innerW = this._getTimelineInnerWidthPx(total, newPps);
if (this._timelineInner) this._timelineInner.style.width = `${innerW}px`;
const maxScroll = Math.max(0, innerW - vp.clientWidth);
let nextScroll = timeUnder * newPps - relX;
if (!zoomIn && newPps <= minPps * 1.0001) {
nextScroll = 0;
}
vp.scrollLeft = maxScroll <= 0 ? 0 : Math.max(0, Math.min(nextScroll, maxScroll));
// scrollLeft 가 0→0 으로 같으면 scroll 이벤트가 안 나와 `_scheduleViewportScrollVisualSync` 가 안 돈다. 썸 너비는 innerW/pps 에 따라 바로 맞춘다.
this._updateTimelineScrollBarUI();
this._requestTimelinePaint();
},
{ passive: false }
);
}
// 자식(재생 헤드·눈금 텍스트)이 밖으로 삐져나가면 scrollWidth 가 커져 슬라이더가 과하게 길어진다.
// overflow:hidden 으로 막되, 일부 브라우저/상황에서 scrollLeft 가 논리 범위를 넘으면 여기서 클램프한다.
_bindTimelineViewportScrollClamp() {
const vp = this._timelineViewport;
if (!vp) return;
vp.addEventListener('scroll', () => {
if (!this._timelineInner) return;
this._scheduleViewportScrollVisualSync();
});
}
/** 스크롤/썸 조작 시 눈금·스크롤바를 rAF 1회로만 갱신 (연속 scroll 이벤트 합침). */
_scheduleViewportScrollVisualSync() {
if (this._viewportScrollVisualRaf != null) return;
this._viewportScrollVisualRaf = requestAnimationFrame(() => {
this._viewportScrollVisualRaf = null;
if (!this._timelineInner || !this._panelVisible) return;
const total = this._getTotalDurationSec();
const innerW = this._getTimelineInnerWidthPx(total, this._pixelsPerSecond);
const vport = this._timelineViewport;
if (vport) {
const maxScroll = Math.max(0, innerW - vport.clientWidth);
if (maxScroll <= 0) vport.scrollLeft = 0;
else vport.scrollLeft = Math.max(0, Math.min(vport.scrollLeft, maxScroll));
}
this._updateTimelineScrollBarUI();
const pps = this._pixelsPerSecond;
this._renderRuler(total, pps);
});
}
/** `innerW`·`scrollLeft`에 맞춰 커스텀 가로 스크롤 썸 위치·너비를 맞춘다. */
_updateTimelineScrollBarUI() {
const vp = this._timelineViewport;
const track = this._timelineScrollTrackEl;
const thumb = this._timelineScrollThumbEl;
const wrap = this._timelineScrollBarEl;
if (!vp || !track || !thumb || !wrap) return;
const total = this._getTotalDurationSec();
const innerW = this._getTimelineInnerWidthPx(total, this._pixelsPerSecond);
const cw = Math.max(vp.clientWidth, 1);
const maxScroll = Math.max(0, innerW - cw);
const trackW = track.clientWidth;
if (trackW <= 1) return;
const minT = SoopVeditorReplacement.TIMELINE_SCROLL_THUMB_MIN_PX;
if (maxScroll <= 0) {
wrap.classList.add('vs-disabled');
const w = Math.max(0, trackW - 4);
thumb.style.width = `${w}px`;
thumb.style.left = '2px';
thumb.setAttribute('aria-valuemin', '0');
thumb.setAttribute('aria-valuemax', '0');
thumb.setAttribute('aria-valuenow', '0');
return;
}
wrap.classList.remove('vs-disabled');
const thumbW = Math.max(minT, (cw / innerW) * trackW);
const maxThumbLeft = Math.max(0, trackW - thumbW);
const ratio = maxScroll > 0 ? vp.scrollLeft / maxScroll : 0;
thumb.style.width = `${thumbW}px`;
thumb.style.left = `${ratio * maxThumbLeft}px`;
thumb.setAttribute('aria-valuemin', '0');
thumb.setAttribute('aria-valuemax', String(Math.round(maxScroll)));
thumb.setAttribute('aria-valuenow', String(Math.round(vp.scrollLeft)));
}
// 네이티브 스크롤바 대신 트랙 클릭·썸 드래그로 `scrollLeft`를 맞춘다.
_bindTimelineCustomScrollbar() {
const track = this._timelineScrollTrackEl;
const thumb = this._timelineScrollThumbEl;
if (!track || !thumb) return;
thumb.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
const vport = this._timelineViewport;
if (!vport) return;
const total = this._getTotalDurationSec();
const innerW = this._getTimelineInnerWidthPx(total, this._pixelsPerSecond);
const cw = Math.max(vport.clientWidth, 1);
const maxScroll = Math.max(0, innerW - cw);
if (maxScroll <= 0) return;
const trackW = track.clientWidth;
const thumbW = thumb.clientWidth || SoopVeditorReplacement.TIMELINE_SCROLL_THUMB_MIN_PX;
const maxThumbLeft = Math.max(0, trackW - thumbW);
this._scrollThumbDrag = {
pointerId: e.pointerId,
startX: e.clientX,
startScroll: vport.scrollLeft,
maxScroll,
maxThumbLeft,
};
try {
thumb.setPointerCapture(e.pointerId);
} catch (_) {
/* ignore */
}
});
thumb.addEventListener('pointermove', (e) => {
if (!this._scrollThumbDrag || e.pointerId !== this._scrollThumbDrag.pointerId) return;
e.preventDefault();
this._applyTimelineScrollThumbDrag(e.clientX);
});
thumb.addEventListener('pointerup', (e) => {
this._onTimelineScrollThumbUp(e);
});
thumb.addEventListener('pointercancel', (e) => {
this._onTimelineScrollThumbUp(e);
});
track.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const t = e.target;
if (t instanceof Node && thumb.contains(t)) return;
e.preventDefault();
const vport = this._timelineViewport;
if (!vport) return;
const total = this._getTotalDurationSec();
const innerW = this._getTimelineInnerWidthPx(total, this._pixelsPerSecond);
const cw = Math.max(vport.clientWidth, 1);
const maxScroll = Math.max(0, innerW - cw);
if (maxScroll <= 0) return;
const rect = track.getBoundingClientRect();
const trackW = track.clientWidth || rect.width;
const thumbW = thumb.clientWidth || SoopVeditorReplacement.TIMELINE_SCROLL_THUMB_MIN_PX;
const maxThumbLeft = Math.max(0, trackW - thumbW);
const clickX = e.clientX - rect.left;
let newLeft = clickX - thumbW / 2;
newLeft = Math.max(0, Math.min(newLeft, maxThumbLeft));
const r = maxThumbLeft > 0 ? newLeft / maxThumbLeft : 0;
vport.scrollLeft = r * maxScroll;
this._clampViewportScroll(innerW);
this._scheduleViewportScrollVisualSync();
});
}
_applyTimelineScrollThumbDrag(clientX) {
if (!this._scrollThumbDrag || !this._timelineViewport) return;
const { startX, startScroll, maxScroll, maxThumbLeft } = this._scrollThumbDrag;
if (maxThumbLeft <= 0 || maxScroll <= 0) return;
const dx = clientX - startX;
const scrollPerPx = maxScroll / maxThumbLeft;
let sl = startScroll + dx * scrollPerPx;
sl = Math.max(0, Math.min(sl, maxScroll));
this._timelineViewport.scrollLeft = sl;
this._scheduleViewportScrollVisualSync();
}
/** @param {PointerEvent} [e] */
_onTimelineScrollThumbUp(e) {
const thumb = this._timelineScrollThumbEl;
const d = this._scrollThumbDrag;
if (d && thumb) {
const releaseId = e && e.pointerId === d.pointerId ? e.pointerId : d.pointerId;
try {
if (typeof thumb.hasPointerCapture === 'function' && thumb.hasPointerCapture(releaseId)) {
thumb.releasePointerCapture(releaseId);
}
} catch (_) {
/* ignore */
}
}
this._scrollThumbDrag = null;
}
// 타임라인 너비 변경 시 `_syncUiFromState`로 눈금·줌 하한을 다시 맞춘다.
_bindViewportResize() {
const vp = this._timelineViewport;
if (!vp || typeof ResizeObserver === 'undefined') return;
this._viewportResizeObs = new ResizeObserver(() => {
this._syncUiFromState();
});
this._viewportResizeObs.observe(vp);
}
// 눈금 영역 호버 시 화면 좌표에 맞는 시각 툴팁을 띄운다 (미세 편집 가이드).
_bindRulerHoverTimeTip() {
const vp = this._timelineViewport;
const tip = this._rulerHoverTipEl;
if (!vp || !tip) return;
const hide = () => {
tip.style.display = 'none';
};
const flushRulerTip = () => {
this._rulerTipRaf = null;
const e = this._rulerTipPendingEv;
this._rulerTipPendingEv = null;
if (!e) return;
const r = vp.getBoundingClientRect();
const ly = e.clientY - r.top;
const lx = e.clientX - r.left;
if (ly < 0 || ly > SoopVeditorReplacement.RULER_HEIGHT_PX || lx < 0 || lx > r.width) {
hide();
return;
}
const total = this._getTotalDurationSec();
const sec = this._clientXToTimelineSec(e.clientX, total);
tip.textContent = this._formatHoverTimelineSec(sec);
tip.style.display = 'block';
const tw = tip.offsetWidth || 100;
const th = tip.offsetHeight || 22;
let left = e.clientX + 12;
let top = e.clientY - th - 8;
if (left + tw > window.innerWidth - 4) left = window.innerWidth - tw - 4;
if (left < 4) left = 4;
if (top < 4) top = e.clientY + 16;
if (top + th > window.innerHeight - 4) top = window.innerHeight - th - 4;
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
};
vp.addEventListener('mousemove', (e) => {
this._rulerTipPendingEv = e;
if (this._rulerTipRaf != null) return;
this._rulerTipRaf = requestAnimationFrame(flushRulerTip);
});
vp.addEventListener('mouseleave', () => {
this._rulerTipPendingEv = null;
if (this._rulerTipRaf != null) {
cancelAnimationFrame(this._rulerTipRaf);
this._rulerTipRaf = null;
}
hide();
});
}
// 호버 툴팁에 쓸 초 값을 사람이 읽기 쉬운 문자열로 만든다 (분·초·소수).
_formatHoverTimelineSec(sec) {
if (!Number.isFinite(sec)) return '';
const s = Math.max(0, sec);
if (s < 60) return `${s.toFixed(3)} s`;
if (s < 3600) {
const m = Math.floor(s / 60);
const rem = s - m * 60;
return `${m}:${String(Math.floor(rem)).padStart(2, '0')}.${(rem % 1).toFixed(3).slice(2)}`;
}
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const rem = s - h * 3600 - m * 60;
return `${h}:${String(m).padStart(2, '0')}:${String(Math.floor(rem)).padStart(2, '0')}.${(rem % 1).toFixed(3).slice(2)}`;
}
// `<video>`가 바뀌면 메타데이터·재생 이벤트로 헤드 RAF와 UI 동기화를 건다 (동적 플레이어 대응).
_ensureVideoPlayheadListeners() {
const v = this._getVideo();
if (!v || this._playheadVideoBound === v) return;
this._playheadVideoBound = v;
v.addEventListener('loadedmetadata', () => {
this._initPlayheadFromVideo();
this._syncUiFromState();
});
v.addEventListener('playing', () => this._startPlayheadRaf());
v.addEventListener('pause', this._onVideoPauseSeekForPlayhead);
v.addEventListener('seeked', this._onVideoPauseSeekForPlayhead);
v.addEventListener('ended', this._onVideoPauseSeekForPlayhead);
}
// 패널이 열려 있을 때 매 프레임(rAF) vodCore·video 재생 시각을 따라 노란 헤드를 갱신한다 (일시정지·시크 포함).
_startPlayheadRaf() {
if (!this._panelVisible || !this._playheadEl || this._playheadDragging) return;
if (this._playheadRafId != null) return;
this._playheadRafId = requestAnimationFrame(this._tickPlayheadPanelSync);
}
// 재생 헤드 RAF를 취소해 루프를 멈춘다 (패널 닫기·드래그 시작).
_stopPlayheadRaf() {
if (this._playheadRafId != null) {
cancelAnimationFrame(this._playheadRafId);
this._playheadRafId = null;
}
}
_tickPlayheadPanelSync() {
this._playheadRafId = null;
if (!this._panelVisible || !this._playheadEl || this._playheadDragging) return;
this._refreshCachedGlobalPlaybackTime();
const total = this._getTotalDurationSec();
this._playheadSec = Math.max(0, Math.min(total, this._computePlayheadDisplaySec(total)));
this._updatePlayheadVisual(total);
this._updateSequenceHeaderUi(total);
if (this._panelVisible && !this._playheadDragging) {
this._playheadRafId = requestAnimationFrame(this._tickPlayheadPanelSync);
}
}
// 총 길이와 현재 재생 위치로 재생 헤드 초기 위치를 맞춘다 (메타데이터 로드·패널 마운트 직후).
_initPlayheadFromVideo() {
this._resetPlayheadExtrap();
const total = this._getTotalDurationSec();
this._refreshCachedGlobalPlaybackTime();
this._playheadSec = Math.max(0, Math.min(total, this._cachedGlobalPlaybackSec));
this._updatePlayheadVisual(total);
}
// 비디오 이벤트 시 한 프레임 더 빨리 헤드를 맞춤 (rAF 와 병행; 패널 닫힘이면 무시).
_onVideoPauseSeekForPlayhead() {
if (this._playheadDragging || !this._panelVisible || !this._playheadEl) return;
this._resetPlayheadExtrap();
const total = this._getTotalDurationSec();
this._refreshCachedGlobalPlaybackTime();
this._playheadSec = Math.max(0, Math.min(total, this._cachedGlobalPlaybackSec));
this._updatePlayheadVisual(total);
}
// `_playheadSec`을 총 길이 안으로 클램프하고 헤드 위치·ARIA 값을 갱신한다 (거의 모든 타임라인 갱신 경로).
_updatePlayheadVisual(totalSec) {
if (!this._playheadEl) return;
const total = totalSec !== undefined ? totalSec : this._getTotalDurationSec();
const pps = this._pixelsPerSecond;
const t = Math.max(0, Math.min(total, this._playheadSec));
this._playheadSec = t;
this._playheadEl.style.left = `${t * pps}px`;
const max = Math.max(0, total);
this._playheadEl.setAttribute('aria-valuemin', '0');
this._playheadEl.setAttribute('aria-valuemax', String(max));
this._playheadEl.setAttribute('aria-valuenow', String(Math.round(t * 1000) / 1000));
}
// 현재 헤드 시각으로 실제 VOD 재생을 옮기고 헤드 표시를 맞춘다 (눈금 클릭·헤드 드래그 종료 시).
_seekVideoToPlayhead(totalSec) {
const total = totalSec !== undefined ? totalSec : this._getTotalDurationSec();
const t = Math.max(0, Math.min(total, this._playheadSec));
this._plSeekGlobal(t);
this._updatePlayheadVisual(total);
}
// 화면 X좌표를 타임라인 상의 초 단위 시각으로 변환한다 (눈금 클릭·드래그·툴팁).
_clientXToTimelineSec(clientX, totalSec) {
const vp = this._timelineViewport;
if (!vp) return this._playheadSec;
const rect = vp.getBoundingClientRect();
const x = clientX - rect.left + vp.scrollLeft;
const total = totalSec !== undefined ? totalSec : this._getTotalDurationSec();
const pps = this._pixelsPerSecond;
return Math.max(0, Math.min(total, x / pps));
}
// 눈금 클릭으로 시크·헤드 드래그(끝에서만 시크)·키보드 단축키(V/C 등)를 연결한다.
_bindPlayheadAndKeyboard() {
const vp = this._timelineViewport;
const ph = this._playheadEl;
if (!vp || !ph) return;
vp.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (e.target.closest('.vs-veditor-playhead')) return;
if (!e.target.closest('.vs-veditor-ruler')) return;
e.preventDefault();
const total = this._getTotalDurationSec();
this._playheadSec = this._clientXToTimelineSec(e.clientX, total);
this._seekVideoToPlayhead(total);
});
ph.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
this._playheadDragging = true;
this._stopPlayheadRaf();
const total = this._getTotalDurationSec();
this._playheadSec = this._clientXToTimelineSec(e.clientX, total);
this._updatePlayheadVisual(total);
document.addEventListener('mousemove', this._onPlayheadPointerMove);
document.addEventListener('mouseup', this._onPlayheadPointerUp);
});
window.addEventListener('keydown', this._onPlayheadKeydown, true);
}
// 헤드 드래그 중에는 시각만 갱신하고, seek 는 pointerup 에서 한 번만 보낸다.
_onPlayheadPointerMove(e) {
if (!this._playheadDragging) return;
const total = this._getTotalDurationSec();
this._playheadSec = this._clientXToTimelineSec(e.clientX, total);
this._updatePlayheadVisual(total);
}
// 헤드 드래그 종료 시 seek 1회 후 리스너 제거.
_onPlayheadPointerUp() {
if (!this._playheadDragging) return;
document.removeEventListener('mousemove', this._onPlayheadPointerMove);
document.removeEventListener('mouseup', this._onPlayheadPointerUp);
this._playheadDragging = false;
const total = this._getTotalDurationSec();
this._seekVideoToPlayhead(total);
this._resetPlayheadExtrap();
this._startPlayheadRaf();
}
// 패널이 열린 상태에서 V/C(도구), [/](편집 구간 경계←현재), {/}(재생→편집 구간 경계), Ctrl+D·Ctrl+Z·Delete 단축키를 처리한다.
_onPlayheadKeydown(e) {
if (!this._panelVisible) return;
const t = e.target;
if (!(t instanceof HTMLElement)) return;
if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key === '[' || e.key === ']' || e.key === '{' || e.key === '}')) {
if (t.closest('input, textarea, select, [contenteditable="true"]')) return;
if (this._isClipListBusy()) return;
e.preventDefault();
e.stopPropagation();
if (e.key === '[') {
this._applyCurrentAsStart();
return;
}
if (e.key === ']') {
this._applyCurrentAsEnd();
return;
}
if (e.key === '{') {
this._seekPlaybackToSelectedClipBoundary(false);
return;
}
this._seekPlaybackToSelectedClipBoundary(true);
return;
}
if (!e.ctrlKey && !e.metaKey && (e.key === 'v' || e.key === 'V')) {
if (t.closest('input, textarea, select, [contenteditable="true"]')) return;
e.preventDefault();
e.stopPropagation();
this._setTimelineToolMode('select');
return;
}
if (!e.ctrlKey && !e.metaKey && (e.key === 'c' || e.key === 'C')) {
if (t.closest('input, textarea, select, [contenteditable="true"]')) return;
e.preventDefault();
e.stopPropagation();
this._setTimelineToolMode('cut');
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'd' || e.key === 'D')) {
if (t.closest('input, textarea, select, [contenteditable="true"]')) return;
if (this._isClipListBusy()) return;
e.preventDefault();
e.stopPropagation();
this._duplicateSelectedClip();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) {
if (t.closest('input, textarea, select, [contenteditable="true"]')) return;
if (this._isClipListBusy()) return;
e.preventDefault();
e.stopPropagation();
const snapshot = this._clipUndoStack.pop();
if (snapshot) {
this._clips = snapshot.clips.map((c) => this._cloneClipForUndo(c));
this._selectedClipIndex = Number.isFinite(snapshot.selectedClipIndex) ? snapshot.selectedClipIndex : 0;
this._ensureSelectedClipIndex();
this._syncUiFromState();
}
return;
}
if (e.key === 'Delete' || e.code === 'Delete') {
if (t.closest('input, textarea, select, [contenteditable="true"]')) return;
if (this._isClipListBusy()) return;
const clips = this._getClips();
const i = this._selectedClipIndex;
if (!clips[i]) return;
e.preventDefault();
e.stopPropagation();
this._recordClipUndo();
this._clipRemove(i);
this._ensureSelectedClipIndex();
this._syncUiFromState();
return;
}
}
// 현재 재생 시각 기준 0분 ~ +20초 기본 편집 구간을 추가한다 (리스트 하단 + 버튼).
_addDefaultClip() {
if (this._isClipListBusy()) return;
const cur = this._cachedGlobalPlaybackSec;
const total = this._getTotalDurationSec();
const begin = Math.max(0, cur);
const end = Math.min(total, cur + 20);
this._recordClipUndo();
this._clipAdd(begin, end);
const clips = this._getClips();
this._selectedClipIndex = Math.max(0, clips.length - 1);
this._syncUiFromState();
}
// 선택한 편집 구간의 시작을 현재 재생 시각으로 맞추거나, 편집 구간이 없으면 짧은 구간을 새로 만든다.
_applyCurrentAsStart() {
if (this._isClipListBusy()) return;
if (!this._getVideo()) {
this.warn('video 요소 없음');
return;
}
const clips = this._getClips();
const cur = this._cachedGlobalPlaybackSec;
if (clips.length === 0) {
if (cur < 0) {
window.alert('시작 시각이 음수가 되어 편집 구간을 만들 수 없습니다.');
return;
}
this._recordClipUndo();
this._clipAdd(cur, Math.min(cur + 10, this._getTotalDurationSec()));
this._selectedClipIndex = 0;
} else {
const idx = Math.min(this._selectedClipIndex, clips.length - 1);
const clipRef = clips[idx];
if (cur > clipRef.end) {
window.alert('현재 시각이 종점보다 뒤라 시점으로 설정할 수 없습니다.');
return;
}
this._recordClipUndo();
this._clipUpdate(idx, { begin: cur });
this._syncSelectionToClip(clipRef);
}
this._syncUiFromState();
}
/** 선택한 편집 구간의 시작(false)·끝(true) 시각으로 재생·타임라인 헤드를 옮긴다 ({ / } 단축키). */
_seekPlaybackToSelectedClipBoundary(toEnd) {
if (this._isClipListBusy()) return;
const clips = this._getClips();
if (clips.length === 0) return;
const idx = Math.min(Math.max(0, this._selectedClipIndex), clips.length - 1);
const clip = clips[idx];
if (!clip) return;
const total = this._getTotalDurationSec();
const raw = toEnd ? clip.end : clip.begin;
const t = Math.max(0, Math.min(total, Number(raw)));
if (!Number.isFinite(t)) return;
this._playheadSec = t;
this._resetPlayheadExtrap();
this._plSeekGlobal(t);
this._updatePlayheadVisual(total);
this._updateSequenceHeaderUi(total);
this._refreshCachedGlobalPlaybackTime();
this._startPlayheadRaf();
}
// 선택한 편집 구간의 끝을 현재 재생 시각으로 맞추거나, 편집 구간이 없으면 짧은 구간을 새로 만든다.
_applyCurrentAsEnd() {
if (this._isClipListBusy()) return;
if (!this._getVideo()) {
this.warn('video 요소 없음');
return;
}
const clips = this._getClips();
const cur = this._cachedGlobalPlaybackSec;
if (clips.length === 0) {
const begin = cur - 10;
if (begin < 0) {
window.alert('시작 시각이 음수가 되어 편집 구간을 만들 수 없습니다.');
return;
}
this._recordClipUndo();
this._clipAdd(begin, cur);
this._selectedClipIndex = 0;
} else {
const idx = Math.min(this._selectedClipIndex, clips.length - 1);
const clipRef = clips[idx];
if (cur < clipRef.begin) {
window.alert('현재 시각이 시점보다 앞이라 종점으로 설정할 수 없습니다.');
return;
}
this._recordClipUndo();
this._clipUpdate(idx, { end: cur });
this._syncSelectionToClip(clipRef);
}
this._syncUiFromState();
}
// `_selectedClipIndex`가 편집 구간 개수 범위 안에 있게 보정한다 (삭제·정렬 후).
_ensureSelectedClipIndex() {
const n = this._getClips().length;
if (n === 0) {
this._selectedClipIndex = 0;
return;
}
if (this._selectedClipIndex >= n) this._selectedClipIndex = n - 1;
if (this._selectedClipIndex < 0) this._selectedClipIndex = 0;
}
/** 편집 구간이 없으면 맞춤·복제·삭제 툴바 버튼을 비활성화한다. */
_updateClipToolbarSelectionActions() {
const clips = this._getClips();
const n = clips.length;
const ok = n > 0 && this._selectedClipIndex >= 0 && this._selectedClipIndex < n;
const busy = this._isClipListBusy();
const dis = !ok || busy;
if (this._clipToolbarFitBtn) this._clipToolbarFitBtn.disabled = !ok;
for (const b of [this._clipToolbarDupBtn, this._clipToolbarDelBtn]) {
if (b) b.disabled = dis;
}
if (this._clipToolbarAddBtn) this._clipToolbarAddBtn.disabled = busy;
for (const b of [this._clipToolbarStartBtn, this._clipToolbarEndBtn]) {
if (b) b.disabled = dis;
}
if (this._clipToolbarPlaySelBtn) {
this._clipToolbarPlaySelBtn.disabled = !ok || this._playAllMode;
const segLabel = this._playSingleClipMode ? '[■]' : '[▶]';
this._clipToolbarPlaySelBtn.textContent = segLabel
this._clipToolbarPlaySelBtn.title = this._playSingleClipMode ? '선택된 편집 구간 정지' : '선택된 편집 구간 재생';
this._clipToolbarPlaySelBtn.setAttribute('aria-label', segLabel);
}
if (this._clipToolbarTestBtn) {
this._clipToolbarTestBtn.textContent = this._playAllMode ? '테스트 중지' : '테스트 시작';
this._clipToolbarTestBtn.title = this._playAllMode ? '테스트 중지' : '테스트 시작';
}
if (this._timelineCopyActionSel) this._timelineCopyActionSel.disabled = n === 0 || busy;
if (this._clipToolbarPublishBtn) this._clipToolbarPublishBtn.disabled = busy;
}
// 객체 참조로 선택 행을 맞춘 뒤 인덱스 범위를 다시 검증한다 (표 변경·드래그 후).
_syncSelectionToClip(clipRef) {
if (clipRef) {
const i = this._getClips().indexOf(clipRef);
if (i >= 0) this._selectedClipIndex = i;
}
this._ensureSelectedClipIndex();
}
/**
* 다음 프레임에 타임라인을 다시 그린다 (디바운스).
* @param {boolean} [rulerOnly] true 이면 가로 스크롤 등으로 보이는 눈금 구간만 갱신 (편집 구간·헤드는 생략).
*/
_requestTimelinePaint(rulerOnly) {
if (this._wheelPaintRaf) cancelAnimationFrame(this._wheelPaintRaf);
this._wheelPaintRaf = requestAnimationFrame(() => {
this._wheelPaintRaf = 0;
const total = this._getTotalDurationSec();
const pps = this._pixelsPerSecond;
this._renderRuler(total, pps);
if (!rulerOnly) {
this._renderClipsOnTrack(total, pps);
this._updatePlayheadVisual(total);
}
this._updateTimelineScrollBarUI();
});
}
// --- 타임라인·편집 구간 패널 뷰 동기화 (조율자 `_syncUiFromState`) ---
// 내부 상태를 기준으로 타임라인 너비·눈금·트랙·표·미리보기·헤드를 전부 동기화한다 (데이터 변경의 단일 진입점).
_syncUiFromState() {
this._migrateClipShape();
this._ensureVideoPlayheadListeners();
const total = this._getTotalDurationSec();
this._ensureSelectedClipIndex();
const vp = this._timelineViewport;
let initialWindowStartSec = null;
if (
this._veditorInitialTimelineZoomPending &&
this._timelineInner &&
total > 0 &&
vp &&
vp.clientWidth > 0
) {
this._veditorInitialTimelineZoomPending = false;
this._refreshCachedGlobalPlaybackTime();
const cur = Math.max(0, Math.min(total, this._cachedGlobalPlaybackSec));
const start = Math.max(0, cur - 300);
const end = Math.min(total, cur + 300);
const span = Math.max(1e-6, end - start);
this._pixelsPerSecond = this._clampPps(vp.clientWidth / span, total);
initialWindowStartSec = start;
}
this._pixelsPerSecond = this._clampPps(this._pixelsPerSecond, total);
const pps = this._pixelsPerSecond;
const innerW = this._getTimelineInnerWidthPx(total, pps);
if (this._timelineInner) this._timelineInner.style.width = `${innerW}px`;
if (vp && initialWindowStartSec !== null) {
vp.scrollLeft = Math.max(0, initialWindowStartSec * pps);
}
this._clampViewportScroll(innerW);
this._renderRuler(total, pps);
this._renderClipsOnTrack(total, pps);
this._syncClipList();
this._updateTimelineToolModeUi();
this._updateClipToolbarSelectionActions();
this._updateSequenceHeaderUi(total);
this._updatePlayheadVisual(total);
queueMicrotask(() => this._updateTimelineScrollBarUI());
}
// 현재 스크롤·줌 기준 보이는 시간 구간 [t0,t1](초)을 여유 패딩 포함해 구한다 (눈금만 그릴 때).
_getVisibleTimelineRangeSec(total, pps) {
const vp = this._timelineViewport;
const sl = vp ? vp.scrollLeft : 0;
const vw = vp ? Math.max(vp.clientWidth, 1) : 1;
const padSec = 40 / pps;
let t0 = Math.max(0, sl / pps - padSec);
let t1 = Math.min(total, (sl + vw) / pps + padSec);
if (t1 <= t0) {
t0 = 0;
t1 = total;
}
return { t0, t1 };
}
// 주 눈금 간격에서 보조 눈금 간격(초)을 고른다 (픽셀 간격이 너무 촘촘해지지 않게).
_minorStepFromMajor(majorStep, pps) {
if (!Number.isFinite(majorStep) || majorStep <= 0) return 0;
const MIN_PX = 5;
for (const n of [10, 5, 4, 2]) {
const m = majorStep / n;
if (m * pps >= MIN_PX && m < majorStep - 1e-12) return m;
}
return 0;
}
// 시각 t가 주 눈금 격자에 거의 걸리는지 본다 (보조 눈금과 겹침 방지).
_isNearlyOnMajorTick(t, majorStep) {
if (!Number.isFinite(majorStep) || majorStep <= 0) return false;
const q = t / majorStep;
return Math.abs(q - Math.round(q)) < 1e-4;
}
// 보이는 구간과 줌에 맞는 주/보조 눈금 간격을 결정한다 (`_renderRuler` 직전).
_getRulerTickLayout(total, pps) {
const { t0, t1 } = this._getVisibleTimelineRangeSec(total, pps);
const span = Math.max(t1 - t0, 1e-6);
const minStepFromPx = 52 / pps;
const targetTicks = 10;
const idealStep = span / targetTicks;
const rough = Math.max(minStepFromPx, idealStep);
const majorStep = this._pickEditorRulerStepSec(rough);
const minorStep = this._minorStepFromMajor(majorStep, pps);
return { t0, t1, majorStep, minorStep };
}
/**
* @param {number} total
* @param {number} pps
*/
_renderRuler(total, pps) {
if (!this._rulerEl) return;
const { t0, t1, majorStep, minorStep } = this._getRulerTickLayout(total, pps);
this._rulerEl.innerHTML = '';
if (minorStep > 0) {
let nMin = 0;
const tMinor0 = Math.floor(t0 / minorStep) * minorStep;
for (let t = tMinor0; t <= t1 + minorStep * 0.001 && nMin < SoopVeditorReplacement.MAX_MINOR_TICKS; t += minorStep) {
if (t < -0.001 || t > total + 0.001) continue;
if (this._isNearlyOnMajorTick(t, majorStep)) continue;
const m = document.createElement('div');
m.className = 'vs-veditor-tick-minor';
m.style.left = `${t * pps}px`;
this._rulerEl.appendChild(m);
nMin++;
}
}
const tStart = Math.floor(t0 / majorStep) * majorStep;
let nMaj = 0;
for (let t = tStart; t <= t1 + majorStep * 0.001 && nMaj < SoopVeditorReplacement.MAX_RULER_TICKS; t += majorStep) {
if (t < -0.001 || t > total + 0.001) continue;
const tick = document.createElement('div');
tick.className = 'vs-veditor-tick-major';
tick.style.left = `${t * pps}px`;
tick.textContent = this._formatTimeLabel(t, majorStep);
this._rulerEl.appendChild(tick);
nMaj++;
}
}
// 대략적인 목표 간격(초)에 맞는 표준 눈금 스텝을 고르거나 배수로 확장한다.
_pickEditorRulerStepSec(roughSec) {
if (!Number.isFinite(roughSec) || roughSec <= 0) return 1;
for (const s of SoopVeditorReplacement.EDITOR_RULER_STEPS_SEC) {
if (s >= roughSec - 1e-12) return s;
}
let s =
SoopVeditorReplacement.EDITOR_RULER_STEPS_SEC[
SoopVeditorReplacement.EDITOR_RULER_STEPS_SEC.length - 1
];
while (s < roughSec) s *= 2;
return s;
}
// 눈금에 찍을 시각 레이블 문자열을 만든다 (스텝이 작으면 소수 초 표기).
_formatTimeLabel(sec, stepSec) {
if (!Number.isFinite(sec)) return '';
const st = stepSec !== undefined && stepSec > 0 ? stepSec : 1;
const subSecTicks = st < 1;
const dec = st >= 0.1 ? 1 : 2;
const quant = 10 ** dec;
const sDisp = subSecTicks ? Math.round(sec * quant) / quant : Math.round(sec);
if (!subSecTicks) {
if (sDisp < 60) return `${sDisp}s`;
if (sDisp < 3600) {
const m = Math.floor(sDisp / 60);
const s = Math.floor(sDisp % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
const h = Math.floor(sDisp / 3600);
const m = Math.floor((sDisp % 3600) / 60);
const s = Math.floor(sDisp % 60);
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
if (sDisp < 60) return `${sDisp.toFixed(dec)}s`;
if (sDisp < 3600) {
const mi = Math.floor(sDisp / 60);
const ss = sDisp - mi * 60;
const ssStr = ss.toFixed(dec);
const [intP, fracP] = ssStr.split('.');
return `${mi}:${intP.padStart(2, '0')}.${fracP}`;
}
const h = Math.floor(sDisp / 3600);
const rem = sDisp - h * 3600;
const mi = Math.floor(rem / 60);
const ss = rem - mi * 60;
const ssStr = ss.toFixed(dec);
const [intP, fracP] = ssStr.split('.');
return `${h}:${String(mi).padStart(2, '0')}:${intP.padStart(2, '0')}.${fracP}`;
}
/**
* @param {number} total
* @param {number} pps
*/
_renderClipsOnTrack(total, pps) {
if (!this._trackEl) return;
this._trackEl.innerHTML = '';
this._trackEl.classList.toggle('vs-tool-cut', this._timelineToolMode === 'cut');
const clips = this._getClips();
clips.forEach((c, idx) => {
if (c.visibleOnTimeline === false) return;
const el = document.createElement('div');
el.className = 'vs-veditor-clip';
el.dataset.clipIndex = String(idx);
if (idx === this._selectedClipIndex) el.classList.add('vs-selected');
el.style.left = `${c.begin * pps}px`;
el.style.width = `${Math.max(2, (c.end - c.begin) * pps)}px`;
const leftH = document.createElement('div');
leftH.className = 'vs-veditor-clip-handle vs-left';
leftH.title = '시작 지점';
const body = document.createElement('div');
body.className = 'vs-veditor-clip-body';
body.title = '드래그하여 편집 구간 이동';
const rightH = document.createElement('div');
rightH.className = 'vs-veditor-clip-handle vs-right';
rightH.title = '끝 지점';
const label = document.createElement('div');
label.className = 'vs-veditor-clip-order';
label.textContent = this._timelineClipLabelMode === 'name' ? c.name : String(idx + 1);
label.title = this._timelineClipLabelMode === 'name' ? c.name : `편집 구간 순서 ${idx + 1}`;
el.appendChild(leftH);
el.appendChild(body);
el.appendChild(label);
el.appendChild(rightH);
leftH.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
if (this._timelineToolMode === 'cut') {
this._splitClipAtClientX(idx, e.clientX, total);
return;
}
const root = el;
this._beginClipDrag(c, 'resize-start', e.clientX, total, root);
});
body.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
this._selectedClipIndex = idx;
if (this._timelineToolMode === 'cut') {
this._splitClipAtClientX(idx, e.clientX, total);
return;
}
const root = el;
this._beginClipDrag(c, 'move', e.clientX, total, root);
});
rightH.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
if (this._timelineToolMode === 'cut') {
this._splitClipAtClientX(idx, e.clientX, total);
return;
}
const root = el;
this._beginClipDrag(c, 'resize-end', e.clientX, total, root);
});
this._trackEl.appendChild(el);
});
}
_setTimelineToolMode(mode) {
if (mode !== 'select' && mode !== 'cut') return;
if (this._timelineToolMode === mode) return;
this._timelineToolMode = mode;
this._updateTimelineToolModeUi();
this._renderClipsOnTrack(this._getTotalDurationSec(), this._pixelsPerSecond);
}
_updateTimelineToolModeUi() {
const isSelect = this._timelineToolMode === 'select';
const isCut = this._timelineToolMode === 'cut';
if (this._timelineToolSelectBtn) {
this._timelineToolSelectBtn.classList.toggle('vs-active', isSelect);
this._timelineToolSelectBtn.setAttribute('aria-pressed', isSelect ? 'true' : 'false');
}
if (this._timelineToolCutBtn) {
this._timelineToolCutBtn.classList.toggle('vs-active', isCut);
this._timelineToolCutBtn.setAttribute('aria-pressed', isCut ? 'true' : 'false');
}
if (this._trackEl) this._trackEl.classList.toggle('vs-tool-cut', isCut);
}
/**
* 자르기로 생기는 오른쪽 구간 이름. 이미 `... (n)` 꼴이면 stem을 괄호 앞까지로 보고 `(n+1)`… 로 채번(예: `ABC (2)` → `ABC (3)`).
* 그 외에는 전체 이름 뒤에 `(2)`, `(3)`… 를 붙인다. 이름이 비면 `편집 구간 (행번호)` 기준.
*/
_allocateNameForClipSplit(sourceClip, sourceIdx) {
const trimmed = String(sourceClip?.name ?? '').trim();
const fallbackBase = trimmed !== '' ? trimmed : `편집 구간 ${sourceIdx + 1}`;
const used = new Set(
this._clips.map((c) => String(c.name ?? '').trim()).filter((s) => s !== '')
);
const parenSuffix = fallbackBase.match(/^(.+)\s\((\d+)\)$/);
if (parenSuffix) {
const stem = parenSuffix[1];
const n0 = parseInt(parenSuffix[2], 10);
if (Number.isFinite(n0)) {
for (let k = n0 + 1; k < 10000; k++) {
const candidate = `${stem} (${k})`;
if (!used.has(candidate)) return candidate;
}
return `${stem} (${Date.now()})`;
}
}
for (let n = 2; n < 10000; n++) {
const candidate = `${fallbackBase} (${n})`;
if (!used.has(candidate)) return candidate;
}
return `${fallbackBase} (${Date.now()})`;
}
_splitClipAtClientX(clipIdx, clientX, totalSec) {
if (this._isClipListBusy()) return;
const clip = this._clips[clipIdx];
if (!clip) return;
const total = totalSec !== undefined ? totalSec : this._getTotalDurationSec();
const minD = SoopVeditorReplacement.CLIP_MIN_DURATION;
let cut = this._roundClipSec(this._clientXToTimelineSec(clientX, total));
if (!Number.isFinite(cut)) return;
cut = Math.max(clip.begin + minD, Math.min(clip.end - minD, cut));
if (!(cut > clip.begin + 1e-9 && cut < clip.end - 1e-9)) return;
this._recordClipUndo();
const oldEnd = clip.end;
clip.end = cut;
this._clips.splice(clipIdx + 1, 0, {
name: this._allocateNameForClipSplit(clip, clipIdx),
begin: cut,
end: oldEnd,
visibleOnTimeline: clip.visibleOnTimeline !== false,
});
this._selectedClipIndex = clipIdx + 1;
this._syncUiFromState();
}
// 편집 구간 리사이즈 또는 이동 드래그를 시작하고 document에 move/up 리스너를 단다.
/**
* @param {VeditorClip} clip
* @param {string} mode
* @param {number} clientX
* @param {number} total
* @param {HTMLElement|null} [dragRootEl] `.vs-veditor-clip` 루트 — 있으면 드래그 중 전체 트랙 재빌드 대신 위치만 갱신
*/
_beginClipDrag(clip, mode, clientX, total, dragRootEl) {
if (this._isClipListBusy()) return;
this._clipDrag = {
clip,
mode,
startX: clientX,
origBegin: clip.begin,
origEnd: clip.end,
total,
undoSnapshot: this._snapshotClipStateForUndo(),
dragEl: dragRootEl instanceof HTMLElement ? dragRootEl : null,
moveRaf: 0,
pendingClientX: clientX,
};
document.addEventListener('mousemove', this._onClipResizeMove);
document.addEventListener('mouseup', this._onClipResizeEnd);
}
/**
* 드래그 중 모델·(가능하면) 해당 편집 구간 DOM만 갱신. mousemove 는 rAF 로 합쳐 프레임당 1회.
* @param {number} clientX
*/
_applyClipDragAtClientX(clientX) {
const d = this._clipDrag;
if (!d) return;
const { clip, mode, startX, origBegin, origEnd, total } = d;
const pps = this._pixelsPerSecond;
const dt = (clientX - startX) / pps;
const minD = SoopVeditorReplacement.CLIP_MIN_DURATION;
if (mode === 'resize-start') {
let begin = origBegin + dt;
begin = Math.max(0, Math.min(begin, origEnd - minD));
clip.begin = begin;
} else if (mode === 'resize-end') {
let end = origEnd + dt;
end = Math.min(total, Math.max(end, origBegin + minD));
clip.end = end;
} else if (mode === 'move') {
let begin = origBegin + dt;
let end = origEnd + dt;
if (begin < 0) {
end -= begin;
begin = 0;
}
if (end > total) {
const over = end - total;
begin -= over;
end = total;
if (begin < 0) begin = 0;
}
clip.begin = begin;
clip.end = end;
}
const el = d.dragEl;
if (el && el.isConnected) {
el.style.left = `${clip.begin * pps}px`;
el.style.width = `${Math.max(2, (clip.end - clip.begin) * pps)}px`;
} else {
this._renderClipsOnTrack(total, pps);
}
}
_onClipResizeMove(e) {
const d = this._clipDrag;
if (!d) return;
d.pendingClientX = e.clientX;
if (d.moveRaf) return;
d.moveRaf = requestAnimationFrame(() => {
if (!this._clipDrag) return;
this._clipDrag.moveRaf = 0;
this._applyClipDragAtClientX(this._clipDrag.pendingClientX);
});
}
// 편집 구간 드래그를 끝내고 선택·전체 UI 동기화로 마무리한다 (시간순 자동 정렬 없음).
_onClipResizeEnd() {
const d = this._clipDrag;
if (!d) return;
document.removeEventListener('mousemove', this._onClipResizeMove);
document.removeEventListener('mouseup', this._onClipResizeEnd);
if (d.moveRaf) {
cancelAnimationFrame(d.moveRaf);
d.moveRaf = 0;
}
this._applyClipDragAtClientX(d.pendingClientX);
const clipRef = d.clip;
const total = d.total;
const minD = SoopVeditorReplacement.CLIP_MIN_DURATION;
clipRef.begin = this._roundClipSec(clipRef.begin);
clipRef.end = this._roundClipSec(clipRef.end);
if (Number.isFinite(clipRef.begin) && Number.isFinite(clipRef.end)) {
if (clipRef.end < clipRef.begin + minD) clipRef.end = clipRef.begin + minD;
if (Number.isFinite(total) && clipRef.end > total) clipRef.end = total;
}
const changed = Math.abs(clipRef.begin - d.origBegin) > 1e-9 || Math.abs(clipRef.end - d.origEnd) > 1e-9;
if (changed && d.undoSnapshot) {
this._clipUndoStack.push(d.undoSnapshot);
if (this._clipUndoStack.length > this._clipUndoMaxDepth) {
this._clipUndoStack.splice(0, this._clipUndoStack.length - this._clipUndoMaxDepth);
}
}
this._clipDrag = null;
this._syncSelectionToClip(clipRef);
this._syncUiFromState();
}
_updateSequenceHeaderUi(totalSec) {
const total = totalSec !== undefined ? totalSec : this._getTotalDurationSec();
if (this._timecodeEl) {
this._timecodeEl.textContent = this._formatSequenceTimecode(this._playheadSec);
}
if (this._clipPlayStatusEl) {
const c = this._clips[this._playAllIndex];
const show = this._playAllMode && !!c;
this._clipPlayStatusEl.style.display = show ? 'inline' : 'none';
this._clipPlayStatusEl.textContent = show ? `(${this._playAllIndex + 1}/${this._clips.length})` : '';
}
if (this._clipTotalEl) {
const sum = this._clipTotalDurationSec();
this._clipTotalEl.textContent = this._formatClipTotalSumLabel(sum);
}
}
/** 편집 구간 시각(초)을 UI·저장용으로 소수 둘째 자리까지 맞춘다. */
_roundClipSec(sec) {
const n = Number(sec);
if (!Number.isFinite(n)) return n;
return Math.round(n * 100) / 100;
}
_formatClipTimeInput(sec) {
const r = this._roundClipSec(sec);
if (!Number.isFinite(r)) return '';
const s = Math.max(0, r);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const secWhole = Math.floor(s % 60);
const centi = Math.round((s - Math.floor(s)) * 100);
const carry = centi >= 100 ? 1 : 0;
const secNorm = secWhole + carry;
const secDisp = secNorm % 60;
const minNorm = m + Math.floor(secNorm / 60);
const minDisp = minNorm % 60;
const hourDisp = h + Math.floor(minNorm / 60);
const centiDisp = carry ? 0 : centi;
return `${String(hourDisp).padStart(2, '0')}:${String(minDisp).padStart(2, '0')}:${String(secDisp).padStart(2, '0')}.${String(centiDisp).padStart(2, '0')}`;
}
/**
* 편집 구간 시각 입력 파서.
* - `HH:MM:SS` 또는 `MM:SS` 허용
* - 레거시 호환: 콜론 없는 초 실수(`123.45`)만 허용, 음수는 NaN
* @param {string} raw
* @returns {number}
*/
_parseClipTimeInput(raw) {
const v = String(raw || '').trim();
if (!v) return Number.NaN;
if (!v.includes(':')) {
const x = parseFloat(v);
if (!Number.isFinite(x) || x < 0) return Number.NaN;
return x;
}
const parts = v.split(':').map((x) => x.trim());
if (parts.length < 2 || parts.length > 3) return Number.NaN;
const nums = parts.map((x) => parseFloat(x));
if (nums.some((n) => !Number.isFinite(n))) return Number.NaN;
const [a, b, c] = parts.length === 2 ? [0, nums[0], nums[1]] : nums;
if (a < 0 || b < 0 || c < 0) return Number.NaN;
return a * 3600 + b * 60 + c;
}
_formatSequenceTimecode(sec) {
if (!Number.isFinite(sec)) return '—';
const s = Math.max(0, sec);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s - h * 3600 - m * 60;
const frac = (r % 1).toFixed(3).slice(2);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(Math.floor(r)).padStart(2, '0')}.${frac}`;
}
/** 댓글 타임라인용 HH:MM:SS (소수점 버림). */
_formatTimelineCommentTimeSec(sec) {
const s = Math.max(0, Math.floor(Number(sec) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
}
_buildTimelineCommentCopyText() {
const clips = this._getClips();
const mode = this._timelineCopyActionSel?.value === 'suffix'
? 'suffix'
: this._timelineCopyActionSel?.value === 'none'
? 'none'
: 'prefix';
return clips
.map((c) => {
const b = this._formatTimelineCommentTimeSec(c.begin);
const e = this._formatTimelineCommentTimeSec(c.end);
const base = `${b} ~ ${e}`;
const name = String(c.name || '').trim();
if (!name || mode === 'none') return base;
return mode === 'suffix' ? `${base} ${name}` : `${name} ${base}`;
})
.join('\n');
}
async _copyClipsAsTimelineComment() {
const text = this._buildTimelineCommentCopyText();
if (!text) {
window.alert('복사할 편집 구간이 없습니다.');
return;
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', 'true');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
this.log('타임라인 댓글 복사 완료');
} catch (e) {
this.error('타임라인 댓글 복사 실패', e);
window.alert('편집 구간 타임라인 복사에 실패했습니다.');
}
}
_duplicateSelectedClip() {
if (this._isClipListBusy()) return;
const i = this._selectedClipIndex;
const c = this._clips[i];
if (!c) return;
this._recordClipUndo();
const nextName = `편집 구간 ${this._clips.length + 1}`;
const copy = {
name: nextName,
begin: c.begin,
end: c.end,
visibleOnTimeline: c.visibleOnTimeline !== false,
};
this._clips.splice(i + 1, 0, copy);
this._selectedClipIndex = i + 1;
this._syncUiFromState();
}
_onClipListChange(e) {
if (this._isClipListBusy()) return;
const t = e.target;
if (!(t instanceof HTMLInputElement) || t.dataset.clip === undefined) return;
const clipIdx = parseInt(t.dataset.clip, 10);
const field = t.dataset.field;
const clips = this._getClips();
const clipRef = clips[clipIdx];
if (!clipRef) return;
let changed = false;
if (field === 'name') {
changed = String(clipRef.name) !== t.value;
if (!changed) return;
this._recordClipUndo();
this._clipUpdate(clipIdx, { name: t.value });
} else if (field === 'begin') {
const val = this._roundClipSec(this._parseClipTimeInput(t.value));
if (Number.isNaN(val)) {
window.alert('시간 형식을 인식할 수 없습니다.');
this._syncUiFromState();
return;
}
if (val > clipRef.end) {
window.alert('시작 시각은 종점보다 뒤일 수 없습니다.');
this._syncUiFromState();
return;
}
changed = Math.abs(Number(clipRef.begin) - val) > 1e-9;
if (!changed) return;
this._recordClipUndo();
this._clipUpdate(clipIdx, { begin: val });
} else if (field === 'end') {
const val = this._roundClipSec(this._parseClipTimeInput(t.value));
if (Number.isNaN(val)) {
window.alert('시간 형식을 인식할 수 없습니다.');
this._syncUiFromState();
return;
}
if (val < clipRef.begin) {
window.alert('종점 시각은 시점보다 앞일 수 없습니다.');
this._syncUiFromState();
return;
}
changed = Math.abs(Number(clipRef.end) - val) > 1e-9;
if (!changed) return;
this._recordClipUndo();
this._clipUpdate(clipIdx, { end: val });
} else {
return;
}
this._syncSelectionToClip(clipRef);
this._syncUiFromState();
}
_bindClipListScrollDelegationOnce() {
if (this._clipListDelegationBound || !this._clipListScrollEl) return;
this._clipListDelegationBound = true;
const el = this._clipListScrollEl;
el.addEventListener('click', this._onClipListHostClick);
el.addEventListener('dragstart', this._onClipListHostDragStart);
el.addEventListener('dragover', this._onClipListHostDragOver);
el.addEventListener('drop', this._onClipListHostDrop);
el.addEventListener('dragend', this._onClipListHostDragEnd);
}
_onClipListHostClick(e) {
const host = this._clipListScrollEl;
if (!host) return;
const t = e.target;
if (!(t instanceof HTMLElement)) return;
if (t.closest('.vs-veditor-clip-add-row .vs-veditor-clip-add-btn')) {
if (this._isClipListBusy()) return;
e.preventDefault();
this._addDefaultClip();
return;
}
const eye = t.closest('.vs-veditor-clip-eye-btn');
if (eye && host.contains(eye)) {
if (this._isClipListBusy()) return;
e.stopPropagation();
const row = eye.closest('.vs-veditor-clip-row');
if (!(row instanceof HTMLElement)) return;
const i = parseInt(row.dataset.clipRow, 10);
const clip = this._clips[i];
if (!clip) return;
this._recordClipUndo();
clip.visibleOnTimeline = clip.visibleOnTimeline === false;
this._syncUiFromState();
return;
}
const row = t.closest('.vs-veditor-clip-row:not(.vs-veditor-clip-add-row)');
if (!(row instanceof HTMLElement) || !host.contains(row)) return;
if (t.closest('input, button, textarea')) return;
const i = parseInt(row.dataset.clipRow, 10);
if (Number.isNaN(i)) return;
this._selectedClipIndex = i;
this._syncUiFromState();
}
_onClipListHostDragStart(e) {
if (this._isClipListBusy()) return;
const host = this._clipListScrollEl;
if (!host) return;
const handle = e.target.closest?.('.vs-veditor-clip-drag-handle');
if (!(handle instanceof HTMLButtonElement) || !host.contains(handle) || handle.disabled) return;
const row = handle.closest('.vs-veditor-clip-row:not(.vs-veditor-clip-add-row)');
if (!(row instanceof HTMLElement) || !host.contains(row)) return;
const i = parseInt(row.dataset.clipRow, 10);
if (Number.isNaN(i)) return;
this._listDnDIndex = i;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(i));
}
}
_onClipListHostDragOver(e) {
if (this._isClipListBusy()) return;
const host = this._clipListScrollEl;
if (!host) return;
const targetEl = e.target instanceof Element ? e.target : null;
if (!targetEl) return;
const row = targetEl.closest('.vs-veditor-clip-row');
if (!(row instanceof HTMLElement) || !host.contains(row)) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
}
_onClipListHostDrop(e) {
if (this._isClipListBusy()) return;
const host = this._clipListScrollEl;
if (!host) return;
const targetEl = e.target instanceof Element ? e.target : null;
if (!targetEl) return;
const row = targetEl.closest('.vs-veditor-clip-row');
if (!(row instanceof HTMLElement) || !host.contains(row)) return;
e.preventDefault();
const fromStr = e.dataTransfer ? e.dataTransfer.getData('text/plain') : '';
const from = parseInt(fromStr, 10);
let to = -1;
if (row.classList.contains('vs-veditor-clip-add-row')) {
to = this._clips.length;
} else {
to = parseInt(row.dataset.clipRow, 10);
}
if (Number.isNaN(from) || Number.isNaN(to)) return;
if (to === this._clips.length && from === this._clips.length - 1) return;
if (from === to) return;
this._recordClipUndo();
const originalLen = this._clips.length;
const item = this._clips.splice(from, 1)[0];
let insertTo;
if (to === originalLen) {
insertTo = this._clips.length; // +행(drop end): 항상 맨 뒤
} else {
insertTo = to;
if (from < to) insertTo -= 1;
if (insertTo < 0) insertTo = 0;
if (insertTo > this._clips.length) insertTo = this._clips.length;
}
this._clipListReorderPending = true;
this._clips.splice(insertTo, 0, item);
this._selectedClipIndex = insertTo;
this._listDnDIndex = null;
this._syncUiFromState();
}
_onClipListHostDragEnd() {
this._listDnDIndex = null;
}
/**
* 편집 구간 개수·빈 상태·DnD 순서 변경 시에만 전체 재빌드하고, 그 외에는 기존 행 DOM을 유지한 채 값·선택만 갱신한다.
*/
_syncClipList() {
const host = this._clipListScrollEl;
if (!host) return;
const clips = this._getClips();
const n = clips.length;
const clipRows = Array.from(host.querySelectorAll('.vs-veditor-clip-row:not(.vs-veditor-clip-add-row)'));
const addRow = host.querySelector('.vs-veditor-clip-add-row');
const emptyEl = host.querySelector('.vs-veditor-clip-list-empty');
const needFull =
this._clipListReorderPending ||
!addRow ||
n !== clipRows.length ||
(n === 0 && !emptyEl) ||
(n > 0 && !!emptyEl);
if (needFull) {
this._renderClipListFull();
this._clipListReorderPending = false;
return;
}
if (n === 0) return;
const sel = this._selectedClipIndex;
for (let i = 0; i < n; i++) {
this._patchClipRow(clipRows[i], clips[i], i, i === sel);
}
}
/**
* @param {HTMLElement} row
* @param {VeditorClip} clip
* @param {number} index
* @param {boolean} selected
*/
_patchClipRow(row, clip, index, selected) {
row.dataset.clipRow = String(index);
row.classList.toggle('vs-veditor-clip-row--selected', selected);
row.draggable = false;
const nameInp = row.querySelector('.vs-veditor-clip-name');
if (nameInp instanceof HTMLInputElement) {
nameInp.value = clip.name;
nameInp.dataset.clip = String(index);
nameInp.disabled = this._isClipListBusy();
}
const beginInp = row.querySelector('input[data-field="begin"]');
if (beginInp instanceof HTMLInputElement) {
beginInp.value = this._formatClipTimeInput(clip.begin);
beginInp.dataset.clip = String(index);
beginInp.disabled = this._isClipListBusy();
}
const endInp = row.querySelector('input[data-field="end"]');
if (endInp instanceof HTMLInputElement) {
endInp.value = this._formatClipTimeInput(clip.end);
endInp.dataset.clip = String(index);
endInp.disabled = this._isClipListBusy();
}
const dur = row.querySelector('.vs-veditor-clip-dur');
if (dur) dur.textContent = `${(clip.end - clip.begin).toFixed(2)}s`;
const dragHandle = row.querySelector('.vs-veditor-clip-drag-handle');
if (dragHandle instanceof HTMLButtonElement) {
dragHandle.draggable = !this._isClipListBusy();
dragHandle.disabled = this._isClipListBusy();
}
const eye = row.querySelector('.vs-veditor-clip-eye-btn');
if (eye) {
const vis = clip.visibleOnTimeline !== false;
eye.className = 'vs-veditor-btn vs-veditor-btn-icon vs-veditor-clip-eye-btn' + (vis ? '' : ' vs-veditor-clip-eye-btn--off');
eye.setAttribute('aria-label', vis ? '타임라인에 표시' : '타임라인에서 숨김');
eye.setAttribute('aria-pressed', vis ? 'true' : 'false');
eye.disabled = this._isClipListBusy();
eye.innerHTML = vis ? SoopVeditorReplacement.CLIP_TIMELINE_VISIBILITY_SVG_ON
: SoopVeditorReplacement.CLIP_TIMELINE_VISIBILITY_SVG_OFF;
}
}
_fitTimelineToClipIndex(idx) {
const c = this._clips[idx];
if (!c || !this._timelineViewport) return;
const total = this._getTotalDurationSec();
const span = Math.max(c.end - c.begin, 0.1);
const vp = this._timelineViewport;
const w = Math.max(vp.clientWidth, 1);
this._pixelsPerSecond = this._clampPps(
Math.min(SoopVeditorReplacement.MAX_PPS, (w * 0.88) / span),
total
);
const innerW = this._getTimelineInnerWidthPx(total, this._pixelsPerSecond);
if (this._timelineInner) this._timelineInner.style.width = `${innerW}px`;
const mid = (c.begin + c.end) / 2;
const innerW2 = innerW;
const sl = mid * this._pixelsPerSecond - w / 2;
vp.scrollLeft = Math.max(0, Math.min(sl, Math.max(0, innerW2 - w)));
this._syncUiFromState();
}
/**
* @param {boolean} [pauseVideo] true 이면 마지막 편집 구간까지 재생 완료 시 `<video>` 일시정지.
*/
_stopPlayAll(pauseVideo = false) {
const wasPlaying = this._playAllMode || this._playSingleClipMode;
this._playAllMode = false;
this._playSingleClipMode = false;
this._playbackClipEntered = false;
this._playAllSeekGraceUntil = 0;
if (this._playAllRaf != null) {
cancelAnimationFrame(this._playAllRaf);
this._playAllRaf = null;
}
if (pauseVideo) {
const v = this._getVideo();
if (v && !v.paused) v.pause();
}
if (wasPlaying) this._syncUiFromState();
}
_playAllClips() {
if (this._playAllMode) return;
if (this._playSingleClipMode) this._stopPlayAll(false);
const clips = this._clips;
if (clips.length === 0) return;
this._playAllMode = true;
this._playAllIndex = 0;
this._selectedClipIndex = 0;
this._playbackClipEntered = false;
this._playAllSeekGraceUntil = performance.now() + 200;
this._plSeekGlobal(clips[0].begin);
const v = this._getVideo();
if (v) v.play().catch(() => {});
this._syncUiFromState();
const tick = () => {
if (!this._playAllMode || !this._panelVisible) return;
if (this._playAllSeekGraceUntil && performance.now() < this._playAllSeekGraceUntil) {
this._playAllRaf = requestAnimationFrame(tick);
return;
}
this._playAllSeekGraceUntil = 0;
this._refreshCachedGlobalPlaybackTime();
const list = this._clips;
let i = this._playAllIndex;
if (i >= list.length) {
this._stopPlayAll();
return;
}
const c = list[i];
const t = this._cachedGlobalPlaybackSec;
if (SoopVeditorReplacement.ClipBoundaryPlayback.advance(this, t, c.begin, c.end) === 'segment_end') {
i += 1;
this._playAllIndex = i;
if (i >= list.length) {
this._stopPlayAll(true);
return;
}
this._playbackClipEntered = false;
this._selectedClipIndex = i;
this._ensureSelectedClipIndex();
const totalSel = this._getTotalDurationSec();
const ppsSel = this._pixelsPerSecond;
this._syncClipList();
this._renderClipsOnTrack(totalSel, ppsSel);
this._updatePlayheadVisual(totalSel);
queueMicrotask(() => this._updateTimelineScrollBarUI());
this._updateClipToolbarSelectionActions();
this._plSeekGlobal(list[i].begin);
this._playAllSeekGraceUntil = performance.now() + 180;
}
this._playAllRaf = requestAnimationFrame(tick);
};
this._playAllRaf = requestAnimationFrame(tick);
}
// 우측 편집 구간 목록을 처음부터 다시 만든다 (개수·빈 상태·DnD 순서 변경 시에만 호출).
_renderClipListFull() {
const host = this._clipListScrollEl;
if (!host) return;
host.innerHTML = '';
const clips = this._getClips();
if (clips.length === 0) {
const empty = document.createElement('div');
empty.className = 'vs-veditor-clip-list-empty';
empty.textContent = '편집 구간 없음 — 아래 + 버튼으로 구간을 추가합니다.';
host.appendChild(empty);
} else {
clips.forEach((c, i) => {
const row = document.createElement('div');
row.className = 'vs-veditor-clip-row';
if (i === this._selectedClipIndex) row.classList.add('vs-veditor-clip-row--selected');
row.draggable = false;
row.dataset.clipRow = String(i);
const nameInp = document.createElement('input');
nameInp.type = 'text';
nameInp.className = 'vs-veditor-clip-name';
nameInp.value = c.name;
nameInp.disabled = this._isClipListBusy();
nameInp.dataset.clip = String(i);
nameInp.dataset.field = 'name';
nameInp.title = '편집 구간 이름';
const beginInp = document.createElement('input');
beginInp.type = 'text';
beginInp.inputMode = 'text';
beginInp.autocomplete = 'off';
beginInp.className = 'vs-veditor-clip-time-inp';
beginInp.value = this._formatClipTimeInput(c.begin);
beginInp.disabled = this._isClipListBusy();
beginInp.dataset.clip = String(i);
beginInp.dataset.field = 'begin';
beginInp.title = '시작 시각(HH:MM:SS 또는 초)';
const tilde = document.createElement('span');
tilde.className = 'vs-veditor-clip-time-tilde';
tilde.textContent = '~';
tilde.setAttribute('aria-hidden', 'true');
const endInp = document.createElement('input');
endInp.type = 'text';
endInp.inputMode = 'text';
endInp.autocomplete = 'off';
endInp.className = 'vs-veditor-clip-time-inp';
endInp.value = this._formatClipTimeInput(c.end);
endInp.disabled = this._isClipListBusy();
endInp.dataset.clip = String(i);
endInp.dataset.field = 'end';
endInp.title = '끝 시각(HH:MM:SS 또는 초)';
const dur = document.createElement('span');
dur.className = 'vs-veditor-clip-dur';
dur.textContent = `${(c.end - c.begin).toFixed(2)}s`;
dur.title = '구간 길이(초)';
const dragHandle = document.createElement('button');
dragHandle.type = 'button';
dragHandle.className = 'vs-veditor-clip-drag-handle';
dragHandle.textContent = '⋮⋮';
dragHandle.title = '드래그해서 순서 변경';
dragHandle.setAttribute('aria-label', '드래그해서 순서 변경');
dragHandle.draggable = !this._isClipListBusy();
dragHandle.disabled = this._isClipListBusy();
const eye = document.createElement('button');
eye.type = 'button';
const vis = c.visibleOnTimeline !== false;
eye.className = 'vs-veditor-btn vs-veditor-btn-icon vs-veditor-clip-eye-btn' + (vis ? '' : ' vs-veditor-clip-eye-btn--off');
eye.title = '타임라인에 표시 (끄면 그래프만 숨김 · 재생·합계에는 포함)';
eye.setAttribute('aria-label', vis ? '타임라인에 표시' : '타임라인에서 숨김');
eye.setAttribute('aria-pressed', vis ? 'true' : 'false');
eye.disabled = this._isClipListBusy();
eye.innerHTML = vis ? SoopVeditorReplacement.CLIP_TIMELINE_VISIBILITY_SVG_ON
: SoopVeditorReplacement.CLIP_TIMELINE_VISIBILITY_SVG_OFF;
const left = document.createElement('div');
left.className = 'vs-veditor-clip-row-left';
left.appendChild(eye);
left.appendChild(nameInp);
const center = document.createElement('div');
center.className = 'vs-veditor-clip-row-center';
center.appendChild(beginInp);
center.appendChild(tilde);
center.appendChild(endInp);
const right = document.createElement('div');
right.className = 'vs-veditor-clip-row-right';
right.appendChild(dur);
right.appendChild(dragHandle);
const line = document.createElement('div');
line.className = 'vs-veditor-clip-row-line';
line.appendChild(left);
line.appendChild(center);
line.appendChild(right);
row.appendChild(line);
host.appendChild(row);
});
}
const addRow = document.createElement('div');
addRow.className = 'vs-veditor-clip-row vs-veditor-clip-add-row';
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'vs-veditor-clip-add-btn';
addBtn.textContent = '+';
addBtn.setAttribute('aria-label', '편집 구간 추가');
addBtn.title = '편집 구간 추가';
addBtn.disabled = this._isClipListBusy();
addRow.appendChild(addBtn);
host.appendChild(addRow);
}
}
new SoopAPI();
const tsManager = new SoopTimestampManager();
new SoopVODLinker();
if (/\/player\/\d+/.test(window.location.pathname)) {
new SoopTimelineCommentProcessor();
new SoopVeditorReplacement();
}
new SoopPrevChatViewer();
// 동기화 요청이 있는 경우 타임스탬프 매니저에게 요청
const params = new URLSearchParams(window.location.search);
const url_request_vod_ts = params.get("request_vod_ts");
const url_request_real_ts = params.get("request_real_ts");
if (url_request_vod_ts && tsManager){
const request_vod_ts = parseInt(url_request_vod_ts);
if (url_request_real_ts){ // 페이지 로딩 시간을 추가해야하는 경우.
const request_real_ts = parseInt(url_request_real_ts);
tsManager.RequestGlobalTSAsync(request_vod_ts, request_real_ts);
}
else{
tsManager.RequestGlobalTSAsync(request_vod_ts);
}
// url 지우기
const url = new URL(window.location.href);
url.searchParams.delete('request_vod_ts');
url.searchParams.delete('request_real_ts');
window.history.replaceState({}, '', url.toString());
}
// timeline_sync=1 이면 localStorage에서 페이로드 로드 후 URL에서 제거
const timelineSyncVal = params.get('timeline_sync');
if (timelineSyncVal) {
let payload = null;
try {
const storageKey = 'vodSync_timeline';
const raw = localStorage.getItem(storageKey);
if (raw) {
payload = JSON.parse(raw);
localStorage.removeItem(storageKey);
}
} catch (_) { /* ignore */ }
if (Array.isArray(payload)) {
window.VODSync.timelineCommentProcessor?.receiveTimelineSyncPayload?.(payload);
}
const url = new URL(window.location.href);
url.searchParams.delete('timeline_sync');
window.history.replaceState({}, '', url.toString());
}
}
// ===================== 탬퍼몽키 업데이트 알림 =====================
(function initUpdateNotificationTM() {
if (typeof GM_info === 'undefined' || !GM_info.script || typeof GM_getValue !== 'function' || typeof GM_setValue !== 'function') return;
function compareVersions(version1, version2) {
const v1parts = version1.split('.').map(Number);
const v2parts = version2.split('.').map(Number);
for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) {
const v1part = v1parts[i] || 0;
const v2part = v2parts[i] || 0;
if (v1part > v2part) return 1;
if (v1part < v2part) return -1;
}
return 0;
}
// 네 번째 자릿수만 바뀐 경우 false. 메이저·마이너·패치가 바뀌면 true.
function shouldShowUpdateNotification(oldVersion, newVersion) {
const oldParts = (oldVersion || '').split('.').map(Number);
const newParts = (newVersion || '').split('.').map(Number);
const oldMajor = oldParts[0] || 0, oldMinor = oldParts[1] || 0, oldPatch = oldParts[2] || 0;
const newMajor = newParts[0] || 0, newMinor = newParts[1] || 0, newPatch = newParts[2] || 0;
return oldMajor !== newMajor || oldMinor !== newMinor || oldPatch !== newPatch;
}
const MODAL_HTML_TEMPLATE = `
<div id="vodSyncUpdateModal" style="
position: fixed;
z-index: 999999;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
">
<div id="modalContent" style="
background-color: #fefefe;
margin: auto;
padding: 0;
border-radius: 10px;
width: auto;
min-width: 300px;
max-width: 90vw;
height: auto;
min-height: 200px;
max-height: 90vh;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
animation: vodSyncModalSlideIn 0.3s ease-out;
position: relative;
">
<div style="
background: linear-gradient(135deg, #007bff, #0056b3);
color: white;
padding: 15px 20px;
border-radius: 10px 10px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
">
<h2 style="margin: 0; font-size: 18px; font-weight: 600;"> VOD Master 업데이트 알림</h2>
<span class="vod-sync-close" style="
color: white;
font-size: 28px;
font-weight: bold;
cursor: pointer;
line-height: 1;
">×</span>
</div>
<iframe id="updateIframe" style="
width: 500px;
height: 300px;
border: none;
border-radius: 0 0 10px 10px;
transition: width 0.3s ease, height 0.3s ease;
"></iframe>
</div>
</div>
<style>
@keyframes vodSyncModalSlideIn {
from { opacity: 0; transform: translateY(-50px); }
to { opacity: 1; transform: translateY(0); }
}
.vod-sync-close:hover { opacity: 0.7; }
</style>
`;
function createAndShowUpdateModal(version) {
const existingModal = document.getElementById('vodSyncUpdateModal');
if (existingModal) existingModal.remove();
document.body.insertAdjacentHTML('beforeend', MODAL_HTML_TEMPLATE);
const modal = document.getElementById('vodSyncUpdateModal');
const iframe = document.getElementById('updateIframe');
if (modal && iframe) {
modal.style.display = 'flex';
iframe.src = 'https://ainukehere.github.io/VOD-Master/doc/update_notification_v' + version + '.html';
const closeModal = () => modal.remove();
modal.querySelector('.vod-sync-close').onclick = closeModal;
modal.onclick = function(e) { if (e.target === modal) closeModal(); };
const handleEscKey = function(e) {
if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', handleEscKey); }
};
document.addEventListener('keydown', handleEscKey);
}
}
function resizeIframe(iframe, contentWidth, contentHeight) {
try {
const minWidth = 300, maxWidth = 600, minHeight = 200, maxHeight = 960, headerHeight = 60;
const maxModalHeight = Math.floor(window.innerHeight * 0.9);
const maxIframeHeight = Math.max(minHeight, maxModalHeight - headerHeight);
const newWidth = Math.max(minWidth, Math.min(maxWidth, contentWidth));
const newHeight = Math.max(minHeight, Math.min(maxHeight, maxIframeHeight, contentHeight));
iframe.style.width = newWidth + 'px';
iframe.style.height = newHeight + 'px';
const modalContent = document.getElementById('modalContent');
if (modalContent) {
modalContent.style.width = newWidth + 'px';
modalContent.style.height = Math.min(newHeight + headerHeight, maxModalHeight) + 'px';
}
} catch (e) {
const iframe = document.getElementById('updateIframe');
const modalContent = document.getElementById('modalContent');
if (iframe) { iframe.style.width = '500px'; iframe.style.height = '300px'; }
if (modalContent) { modalContent.style.width = '500px'; modalContent.style.height = '360px'; }
}
}
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'vodSync-iframe-resize') {
const iframe = document.getElementById('updateIframe');
if (iframe) resizeIframe(iframe, event.data.width, event.data.height);
}
});
async function checkForUpdatesTM() {
try {
const currentVersion = (GM_info.script && GM_info.script.version) ? GM_info.script.version : '';
if (!currentVersion) return;
let lastCheckedVersion = GM_getValue('vodSync_lastCheckedVersion', null);
lastCheckedVersion = await Promise.resolve(lastCheckedVersion);
if (typeof lastCheckedVersion !== 'string') lastCheckedVersion = null;
const versionUpgraded = !lastCheckedVersion || compareVersions(currentVersion, lastCheckedVersion) > 0;
if (versionUpgraded) {
const showNotification = !lastCheckedVersion || shouldShowUpdateNotification(lastCheckedVersion, currentVersion);
if (showNotification) createAndShowUpdateModal(currentVersion);
const setResult = GM_setValue('vodSync_lastCheckedVersion', currentVersion);
await Promise.resolve(setResult);
}
} catch (err) {
logToExtension('업데이트 확인 중 오류:', err);
}
}
setTimeout(checkForUpdatesTM, 2000);
})();
})();