/* 플레이리스트 import UI. * * 흐름: * 1) URL 입력 → 목록 불러오기 (POST /playlist/probe) * 2) 항목 목록 렌더 + 인라인 편집 (제목, ID) + 드래그 재정렬 + 항목 삭제 * 3) ID 모드 토글 (랜덤 / 시퀀셜) — 시퀀셜이면 zero-pad 토글 노출 * 4) 등록 → POST /playlist/start → 폴더 페이지로 돌아가서 다운로드 폴링 */ (function () { var ctx = window.__PLAYLIST__ || {} var folderPath = ctx.folderPath || [] var folderPathEnc = folderPath .map(function (s) { return encodeURIComponent(s) }) .join('/') var folderBaseUrl = '/op/folder/' + folderPathEnc var probeUrl = folderBaseUrl + '/playlist/probe' var startUrl = folderBaseUrl + '/playlist/start' var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/ var DEFAULT_LENGTH = 15 // 기본 재생 길이(초) // ─── state ──────────────────────────────────────────────────────────── // entries: [{ url, title, videoId, durationSec, thumbnailUrl, ytId, // startSec:number(기본0), lengthSec:number(기본15, 시작부터 재생할 초), // idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }] var state = { entries: [], idMode: 'random', // 'random' | 'sequential' zeroPad: false, busy: false } // ─── DOM refs ───────────────────────────────────────────────────────── var urlInput = document.getElementById('playlistUrl') var probeBtn = document.getElementById('probeBtn') var probeStatus = document.getElementById('probeStatus') var entriesSection = document.getElementById('entriesSection') var entryList = document.getElementById('entryList') var entriesCount = document.getElementById('entriesCount') var idModeRadios = document.getElementsByName('idMode') var zeroPadWrap = document.getElementById('zeroPadWrap') var zeroPadToggle = document.getElementById('zeroPadToggle') var registerBtn = document.getElementById('registerBtn') var registerStatus = document.getElementById('registerStatus') var probeSection = document.getElementById('probeSection') var progressSection = document.getElementById('progressSection') var progressList = document.getElementById('progressList') var progressSummary = document.getElementById('progressSummary') var progressOverall = document.getElementById('progressOverall') var progressDoneBtn = document.getElementById('progressDoneBtn') // ─── 유틸 ───────────────────────────────────────────────────────────── function randomId() { // 24자 hex (UUID 압축). crypto.randomUUID 가 없는 옛 브라우저 폴백. if (window.crypto && typeof crypto.randomUUID === 'function') { return crypto.randomUUID().replace(/-/g, '').slice(0, 24) } var s = '' for (var i = 0; i < 24; i++) { s += Math.floor(Math.random() * 16).toString(16) } return s } function formatDuration(sec) { if (sec == null || !isFinite(sec)) return '' sec = Math.round(sec) var h = Math.floor(sec / 3600) var m = Math.floor((sec % 3600) / 60) var s = sec % 60 if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0') return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0') } function setProbeStatus(text, cls) { probeStatus.textContent = text || '' probeStatus.className = cls || 'muted' } function setRegisterStatus(text, cls) { registerStatus.textContent = text || '' registerStatus.className = cls || 'muted' } // ─── ID 생성 / 재계산 ───────────────────────────────────────────────── function regenerateIds() { var n = state.entries.length if (state.idMode === 'random') { for (var i = 0; i < n; i++) { state.entries[i].videoId = randomId() state.entries[i].idStatus = 'ok' state.entries[i].idReason = '' } } else { // sequential: 1..n, optional zero-pad based on n var width = state.zeroPad ? String(n).length : 0 for (var j = 0; j < n; j++) { var num = String(j + 1) if (width > 0) while (num.length < width) num = '0' + num state.entries[j].videoId = num state.entries[j].idStatus = 'pending' state.entries[j].idReason = '' } // 시퀀셜은 짧은 숫자 ID 라 충돌 가능성이 상대적으로 크므로 일괄 가용성 확인. for (var k = 0; k < n; k++) { scheduleIdCheck(k, 0) } } } // ─── ID 가용성 확인 (단건) ──────────────────────────────────────────── function scheduleIdCheck(idx, delay) { var entry = state.entries[idx] if (!entry) return if (entry.idDebounce) { clearTimeout(entry.idDebounce); entry.idDebounce = 0 } var v = entry.videoId if (!v) { entry.idStatus = 'bad' entry.idReason = '비어 있음' paintEntryStatus(idx) return } if (!ID_RE.test(v)) { entry.idStatus = 'bad' entry.idReason = '형식: 영문/숫자/_/- (1~64자)' paintEntryStatus(idx) return } // 요청 내 중복 검사 for (var i = 0; i < state.entries.length; i++) { if (i !== idx && state.entries[i].videoId === v) { entry.idStatus = 'bad' entry.idReason = '같은 ID 가 다른 항목에 있음' paintEntryStatus(idx) return } } entry.idStatus = 'pending' entry.idReason = '확인 중…' paintEntryStatus(idx) entry.idDebounce = setTimeout(function () { var current = state.entries[idx] if (!current || current.videoId !== v) return fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v) + '&folderId=' + encodeURIComponent(ctx.folderId), { cache: 'no-store' }) .then(function (r) { return r.json() }) .then(function (j) { var c = state.entries[idx] if (!c || c.videoId !== v) return if (j && j.ok && j.available) { c.idStatus = 'ok' c.idReason = '' } else { c.idStatus = 'bad' c.idReason = (j && j.reason) || '이미 사용 중' } paintEntryStatus(idx) }) .catch(function () { var c = state.entries[idx] if (!c || c.videoId !== v) return c.idStatus = 'pending' c.idReason = '확인 실패 (재시도 자동 대기)' paintEntryStatus(idx) }) }, delay != null ? delay : 250) } // ─── 렌더 ───────────────────────────────────────────────────────────── function render() { entriesCount.textContent = state.entries.length + ' 항목' entryList.innerHTML = '' for (var i = 0; i < state.entries.length; i++) { entryList.appendChild(buildRow(i)) } // sequential 모드에서 아무 항목이라도 충돌 있으면 등록 버튼 비활성. updateRegisterEnable() } function buildRow(idx) { var entry = state.entries[idx] var li = document.createElement('li') li.className = 'playlistEntryRow' li.draggable = true li.dataset.idx = String(idx) li.innerHTML = '' + '' + '' + (idx + 1) + '' + '
' + '
' + '' + '
' + '' + '' + '
' + '
' + '
' + '' + '' + '
' + '' + '' var thumbBox = li.querySelector('.entryThumb') var titleInput = li.querySelector('.entryTitleInput') var idInput = li.querySelector('.entryIdInput') var startInput = li.querySelector('.entryStartInput') var lengthInput = li.querySelector('.entryLengthInput') var durSpan = li.querySelector('.entryDuration') var removeBtn = li.querySelector('.entryRemove') if (entry.thumbnailUrl) { var img = document.createElement('img') img.src = entry.thumbnailUrl img.alt = '' img.loading = 'lazy' // 이미지가 깨지면(예: 비공개/삭제 영상) 빈 박스로 둔다. img.addEventListener('error', function () { thumbBox.classList.add('entryThumbEmpty') }) thumbBox.appendChild(img) } else { thumbBox.classList.add('entryThumbEmpty') } titleInput.value = entry.title || '' idInput.value = entry.videoId || '' durSpan.textContent = formatDuration(entry.durationSec) paintTrimInputs(idx, startInput, lengthInput) titleInput.addEventListener('input', function () { state.entries[idx].title = titleInput.value }) idInput.addEventListener('input', function () { state.entries[idx].videoId = idInput.value.trim() scheduleIdCheck(idx, 250) // 요청 내 중복은 다른 행에도 영향 → 가시적으로 갱신 revalidateOtherIds(idx) }) startInput.addEventListener('change', function () { var e = state.entries[idx] var sec = parseClock(startInput.value) e.startSec = sec != null && sec > 0 ? sec : 0 paintTrimInputs(idx, startInput, lengthInput) }) lengthInput.addEventListener('change', function () { var e = state.entries[idx] var sec = parseClock(lengthInput.value) e.lengthSec = sec != null && sec > 0 ? sec : DEFAULT_LENGTH paintTrimInputs(idx, startInput, lengthInput) }) thumbBox.addEventListener('click', function () { openPreview(idx) }) li.addEventListener('contextmenu', function (ev) { // input/버튼 위에서의 우클릭은 기본 메뉴를 살려 둔다 (텍스트 편집 편의). var t = ev.target if (t && (t.tagName === 'INPUT' || t.tagName === 'BUTTON')) return ev.preventDefault() openEntryCtxMenu(idx, ev.clientX, ev.clientY) }) removeBtn.addEventListener('click', function () { state.entries.splice(idx, 1) // sequential 이면 번호 재계산 if (state.idMode === 'sequential') regenerateIds() render() }) attachRowDnd(li, idx) paintEntryStatusInto(idx, li) return li } // 항목의 시작/길이 입력칸 값을 상태로부터 다시 그린다. function paintTrimInputs(idx, startInput, lengthInput) { var e = state.entries[idx] if (!e) return if (!startInput) { var row = findRow(idx) if (!row) return startInput = row.querySelector('.entryStartInput') lengthInput = row.querySelector('.entryLengthInput') if (!startInput || !lengthInput) return } startInput.value = formatClock(e.startSec || 0) lengthInput.value = formatLength(e.lengthSec) } function findRow(idx) { var rows = entryList.children for (var i = 0; i < rows.length; i++) { if (Number(rows[i].dataset.idx) === idx) return rows[i] } return null } function paintEntryStatus(idx) { var rows = entryList.children for (var i = 0; i < rows.length; i++) { var r = rows[i] if (Number(r.dataset.idx) === idx) { paintEntryStatusInto(idx, r) break } } updateRegisterEnable() } function paintEntryStatusInto(idx, rowEl) { var entry = state.entries[idx] if (!entry) return var statusEl = rowEl.querySelector('.entryIdStatus') if (!statusEl) return if (entry.idStatus === 'ok') { statusEl.textContent = '사용 가능' statusEl.className = 'entryIdStatus idStatusOk' } else if (entry.idStatus === 'bad') { statusEl.textContent = entry.idReason || '오류' statusEl.className = 'entryIdStatus idStatusBad' } else { statusEl.textContent = entry.idReason || '확인 중…' statusEl.className = 'entryIdStatus muted' } } // 한 항목의 ID 가 바뀌면 같은 ID 를 가진 다른 항목의 상태도 새로 평가해야 한다. function revalidateOtherIds(changedIdx) { for (var i = 0; i < state.entries.length; i++) { if (i === changedIdx) continue scheduleIdCheck(i, 0) } } function updateRegisterEnable() { var ok = state.entries.length > 0 && !state.busy if (ok) { for (var i = 0; i < state.entries.length; i++) { if (state.entries[i].idStatus !== 'ok') { ok = false; break } if (!state.entries[i].title || !state.entries[i].title.trim()) { ok = false; break } } } registerBtn.disabled = !ok } // ─── 드래그 재정렬 ──────────────────────────────────────────────────── // 패턴: 원본 요소 자체를 dragover 동안 이동시켜 placeholder 없이 라이브 미리보기. var drag = null // { srcEl } function attachRowDnd(li) { li.addEventListener('dragstart', function (e) { // input 안에서 드래그 시작은 막는다 (텍스트 드래그와 충돌). var t = e.target if (t && t.tagName === 'INPUT') { e.preventDefault() return } drag = { srcEl: li } try { e.dataTransfer.setData('text/plain', li.dataset.idx) e.dataTransfer.effectAllowed = 'move' } catch (_) {} setTimeout(function () { if (drag && drag.srcEl) drag.srcEl.classList.add('dragGhost') }, 0) }) li.addEventListener('dragend', function () { if (drag && drag.srcEl) drag.srcEl.classList.remove('dragGhost') drag = null }) } entryList.addEventListener('dragover', function (e) { if (!drag) return e.preventDefault() try { e.dataTransfer.dropEffect = 'move' } catch (_) {} var target = null for (var i = 0; i < entryList.children.length; i++) { var c = entryList.children[i] if (c === drag.srcEl) continue var rect = c.getBoundingClientRect() if (e.clientY < rect.top + rect.height / 2) { target = c; break } } if (target) { if (drag.srcEl.nextSibling !== target) entryList.insertBefore(drag.srcEl, target) } else { if (drag.srcEl !== entryList.lastChild) entryList.appendChild(drag.srcEl) } }) entryList.addEventListener('drop', function (e) { if (!drag) return e.preventDefault() // 새 순서 = DOM 순서대로 state 배열을 다시 만든다. var newOrder = [] for (var i = 0; i < entryList.children.length; i++) { var idx = Number(entryList.children[i].dataset.idx) newOrder.push(state.entries[idx]) } state.entries = newOrder drag = null if (state.idMode === 'sequential') regenerateIds() render() }) // ─── ID 모드 토글 ───────────────────────────────────────────────────── for (var i = 0; i < idModeRadios.length; i++) { idModeRadios[i].addEventListener('change', function (e) { if (!e.target.checked) return state.idMode = e.target.value zeroPadWrap.hidden = state.idMode !== 'sequential' regenerateIds() render() }) } zeroPadToggle.addEventListener('change', function () { state.zeroPad = zeroPadToggle.checked if (state.idMode === 'sequential') { regenerateIds() render() } }) // ─── probe ──────────────────────────────────────────────────────────── probeBtn.addEventListener('click', function () { var url = (urlInput.value || '').trim() if (!url) { setProbeStatus('플레이리스트 URL 을 입력해 주세요.', 'idStatusBad') return } probeBtn.disabled = true setProbeStatus('목록을 불러오는 중…', 'muted') fetch(probeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url }) }) .then(function (r) { return r.json().then(function (j) { return { status: r.status, body: j } }) }) .then(function (res) { if (!res.body || !res.body.ok) { var msg = (res.body && res.body.message) || ('HTTP ' + res.status) setProbeStatus(msg, 'idStatusBad') return } var raw = res.body.entries || [] state.entries = raw.map(function (e) { return { url: e.url, title: e.title || '제목 없음', durationSec: e.durationSec, thumbnailUrl: e.thumbnailUrl || null, ytId: extractYouTubeId(e.url), videoId: '', startSec: 0, lengthSec: DEFAULT_LENGTH, idStatus: 'pending', idReason: '' } }) regenerateIds() render() entriesSection.hidden = state.entries.length === 0 setProbeStatus(state.entries.length + ' 개 항목을 불러왔습니다.', 'idStatusOk') }) .catch(function (err) { setProbeStatus('요청 실패: ' + (err && err.message ? err.message : err), 'idStatusBad') }) .finally(function () { probeBtn.disabled = false }) }) // ─── register ──────────────────────────────────────────────────────── registerBtn.addEventListener('click', function () { if (registerBtn.disabled) return var payload = { entries: state.entries.map(function (e) { return { url: e.url, title: (e.title || '').trim(), videoId: (e.videoId || '').trim(), startSec: e.startSec || 0, endSec: computeEndSec(e) } }) } state.busy = true registerBtn.disabled = true setRegisterStatus('등록 중…', 'muted') fetch(startUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(function (r) { return r.json().then(function (j) { return { status: r.status, body: j } }) }) .then(function (res) { if (!res.body || !res.body.ok) { var msg = (res.body && res.body.message) || ('HTTP ' + res.status) state.busy = false setRegisterStatus(msg, 'idStatusBad') updateRegisterEnable() return } startDownloadProgress(res.body.jobs || []) }) .catch(function (err) { state.busy = false setRegisterStatus('요청 실패: ' + (err && err.message ? err.message : err), 'idStatusBad') updateRegisterEnable() }) }) // ─── 다운로드 진행 화면 ──────────────────────────────────────────────── // 등록 후 폴더로 바로 나가지 않고, 각 잡을 폴링하며 진행률을 보여준다. // (백엔드는 잡을 큐에 넣고 순차 처리 — queued/downloading/done/error.) function startDownloadProgress(jobs) { if (probeSection) probeSection.hidden = true if (entriesSection) entriesSection.hidden = true if (progressSection) progressSection.hidden = false var items = jobs.map(function (j, i) { var title = (state.entries[i] && state.entries[i].title) || j.videoId return { jobId: j.jobId, videoId: j.videoId, title: title, status: 'queued', progress: 0, message: '대기 중', done: false, error: false, barEl: null, msgEl: null } }) renderProgressRows(items) updateProgressUI(items) if (items.length === 0) { finishProgress(items); return } pollAllJobs(items) } function renderProgressRows(items) { progressList.innerHTML = '' items.forEach(function (it) { var li = document.createElement('li') li.className = 'progressRow' var name = document.createElement('div') name.className = 'progressName' name.textContent = it.title var bar = document.createElement('progress') bar.className = 'progressBar' bar.max = 100 bar.value = 0 var msg = document.createElement('div') msg.className = 'progressMsg muted' msg.textContent = it.message li.appendChild(name) li.appendChild(bar) li.appendChild(msg) progressList.appendChild(li) it.barEl = bar it.msgEl = msg it.rowEl = li }) } function pollAllJobs(items) { var pending = items.filter(function (it) { return !it.done }) if (pending.length === 0) { finishProgress(items); return } Promise.all(pending.map(function (it) { return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' }) .then(function (r) { return r.json() }) .then(function (j) { if (j && j.ok && j.job) { it.status = j.job.status it.message = j.job.message || '' it.progress = typeof j.job.progress === 'number' ? j.job.progress : it.progress if (j.job.status === 'done') { it.done = true it.progress = 100 it.message = '완료' } else if (j.job.status === 'error') { it.done = true it.error = true it.message = '실패: ' + (j.job.error || j.job.message || '') } } }) .catch(function () { /* 일시 오류는 다음 틱에 재시도 */ }) })).then(function () { updateProgressUI(items) if (items.some(function (it) { return !it.done })) { setTimeout(function () { pollAllJobs(items) }, 1500) } else { finishProgress(items) } }) } function updateProgressUI(items) { var total = items.length var doneCount = 0 var sum = 0 items.forEach(function (it) { if (it.barEl) { it.barEl.value = it.progress if (it.error) it.barEl.classList.add('progressBarError') } if (it.msgEl) it.msgEl.textContent = it.message if (it.rowEl) { it.rowEl.classList.toggle('progressRowDone', it.done && !it.error) it.rowEl.classList.toggle('progressRowError', it.error) } if (it.done) doneCount += 1 sum += it.progress }) if (progressOverall) progressOverall.value = total ? Math.round(sum / total) : 0 if (progressSummary) progressSummary.textContent = '다운로드 ' + doneCount + ' / ' + total + ' 완료' } function finishProgress(items) { var errCount = items.filter(function (it) { return it.error }).length if (progressOverall) progressOverall.value = 100 if (progressSummary) { progressSummary.textContent = errCount ? ('완료 — ' + items.length + ' 개 중 ' + errCount + ' 개 실패') : ('전체 ' + items.length + ' 개 다운로드 완료') } var statusEl = document.getElementById('progressStatus') if (statusEl) { statusEl.textContent = errCount ? '실패한 항목은 폴더에서 개별로 다시 시도할 수 있습니다.' : '모든 영상이 폴더에 추가되었습니다.' } if (progressDoneBtn) progressDoneBtn.hidden = false } // ─── 시간 파싱/표시 + YouTube id 추출 ────────────────────────────────── // "ss" / "mm:ss" / "hh:mm:ss" / 소수 초 를 모두 받아 초(number)로. 빈칸/오류는 null. function parseClock(str) { if (str == null) return null str = String(str).trim() if (!str) return null var total = 0 if (str.indexOf(':') >= 0) { var parts = str.split(':') for (var i = 0; i < parts.length; i++) { var n = Number(parts[i]) if (!isFinite(n) || n < 0) return null total = total * 60 + n } } else { var v = Number(str) if (!isFinite(v) || v < 0) return null total = v } return total } // 입력칸 표시용. formatDuration 과 동일 포맷(mm:ss / h:mm:ss). function formatClock(sec) { if (sec == null || !isFinite(sec)) return '' return formatDuration(sec) } function extractYouTubeId(url) { if (!url) return null var m = String(url).match( /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|v\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/ ) return m ? m[1] : null } // 재생 길이(초) 표시용. 정수면 "15", 소수면 "12.3". function formatLength(sec) { if (sec == null || !isFinite(sec) || sec <= 0) return '' var r = Math.round(sec * 10) / 10 return r % 1 === 0 ? String(r) : r.toFixed(1) } // 시작 + 길이 → 백엔드용 종료초. 영상 끝을 넘으면 null(끝까지)로. function computeEndSec(e) { var start = e.startSec || 0 var len = e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH var end = start + len if (e.durationSec != null && end >= e.durationSec - 0.01) return null return end } function clampNum(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) } // ─── 일괄 시작/길이 적용 + 초기화 ────────────────────────────────────── var bulkStart = document.getElementById('bulkStart') var bulkLength = document.getElementById('bulkLength') var bulkApplyBtn = document.getElementById('bulkApplyBtn') var bulkResetBtn = document.getElementById('bulkResetBtn') bulkApplyBtn.addEventListener('click', function () { var s = parseClock(bulkStart.value) var startSec = s != null && s > 0 ? s : 0 var len = parseClock(bulkLength.value) var lengthSec = len != null && len > 0 ? len : DEFAULT_LENGTH for (var i = 0; i < state.entries.length; i++) { var e = state.entries[i] e.startSec = startSec e.lengthSec = lengthSec } render() }) bulkResetBtn.addEventListener('click', function () { for (var i = 0; i < state.entries.length; i++) { state.entries[i].startSec = 0 state.entries[i].lengthSec = DEFAULT_LENGTH } bulkStart.value = '' bulkLength.value = '' render() }) // ─── YouTube IFrame API 준비 ─────────────────────────────────────────── var ytReady = false var ytReadyCbs = [] window.onYouTubeIframeAPIReady = function () { ytReady = true var cbs = ytReadyCbs; ytReadyCbs = [] cbs.forEach(function (cb) { cb() }) } function whenYt(cb) { if (ytReady && window.YT && window.YT.Player) cb() else ytReadyCbs.push(cb) } function resetHost(wrap, id) { if (!wrap) return wrap.innerHTML = '
' } // ─── 미리보기 재생 모달 ──────────────────────────────────────────────── var previewOverlay = document.getElementById('previewOverlay') var previewClose = document.getElementById('previewClose') var previewTitle = document.getElementById('previewTitle') var previewPlayer = null function openPreview(idx) { var e = state.entries[idx] if (!e) return if (!e.ytId) { setRegisterStatus('이 항목은 YouTube 영상이 아니라 미리보기를 지원하지 않습니다.', 'idStatusBad') return } previewTitle.textContent = e.title || '' previewOverlay.hidden = false whenYt(function () { if (previewOverlay.hidden) return // API 준비 전에 닫혔으면 무시 destroyPreview() resetHost(previewOverlay.querySelector('.ytEmbedWrap'), 'previewPlayer') previewPlayer = new YT.Player('previewPlayer', { videoId: e.ytId, playerVars: { autoplay: 1, rel: 0, modestbranding: 1, start: Math.floor(e.startSec || 0) } }) }) } function destroyPreview() { if (previewPlayer) { try { previewPlayer.destroy() } catch (_) {} previewPlayer = null } } function closePreview() { previewOverlay.hidden = true destroyPreview() } previewClose.addEventListener('click', closePreview) previewOverlay.addEventListener('click', function (e) { if (e.target === previewOverlay) closePreview() }) // ─── 우클릭 컨텍스트 메뉴 (자르기) ───────────────────────────────────── var entryCtxMenu = document.getElementById('entryCtxMenu') var ctxIdx = -1 function openEntryCtxMenu(idx, x, y) { ctxIdx = idx entryCtxMenu.hidden = false // 화면 밖으로 넘치지 않게 살짝 보정 var mw = 180, mh = 60 entryCtxMenu.style.left = Math.min(x, window.innerWidth - mw) + 'px' entryCtxMenu.style.top = Math.min(y, window.innerHeight - mh) + 'px' } function closeCtxMenu() { entryCtxMenu.hidden = true } document.addEventListener('click', closeCtxMenu) document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closeCtxMenu(); closePreview(); closeTrim() } }) entryCtxMenu.querySelector('[data-action="trim"]').addEventListener('click', function () { var idx = ctxIdx closeCtxMenu() if (idx >= 0) openTrim(idx) }) // ─── 자르기(트림) 모달 — YouTube 임베드 + 타임라인 ───────────────────── var trimOverlay = document.getElementById('trimOverlay') var trimClose = document.getElementById('trimClose') var trimCancel = document.getElementById('trimCancel') var trimApply = document.getElementById('trimApply') var trimTitle = document.getElementById('trimTitle') var plTrimBar = document.getElementById('plTrimBar') var plTrimRangeFill = document.getElementById('plTrimRangeFill') var plTrimHandleStart = document.getElementById('plTrimHandleStart') var plTrimHandleEnd = document.getElementById('plTrimHandleEnd') var plTrimPlayhead = document.getElementById('plTrimPlayhead') var plTimeReadout = document.getElementById('plTimeReadout') var plTrimDuration = document.getElementById('plTrimDuration') var plStartSec = document.getElementById('plStartSec') var plLengthSec = document.getElementById('plLengthSec') var MIN_SEL = 0.05 var trimPlayer = null var trimPoll = null var selStopAt = null // 선택 재생 정지 지점(초). null 이면 비활성. // 현재 편집 중 컨텍스트: { idx, duration, start, end(null=끝까지) } var tctx = null function tEnd() { return tctx.end != null ? tctx.end : tctx.duration } function formatTimeF(sec) { if (!isFinite(sec) || sec < 0) sec = 0 var m = Math.floor(sec / 60) var s = sec - m * 60 var sStr = s.toFixed(1) if (s < 10) sStr = '0' + sStr return (m < 10 ? '0' + m : '' + m) + ':' + sStr } function renderTrimBar() { if (!tctx) return var d = tctx.duration || 0 var end = tEnd() var startPct = d ? (tctx.start / d) * 100 : 0 var endPct = d ? (end / d) * 100 : 0 plTrimRangeFill.style.left = startPct + '%' plTrimRangeFill.style.width = Math.max(0, endPct - startPct) + '%' plTrimHandleStart.style.left = startPct + '%' plTrimHandleEnd.style.left = endPct + '%' plTrimDuration.textContent = '선택: ' + (end - tctx.start).toFixed(1) + '초' plStartSec.value = (tctx.start || 0).toFixed(2) // 끝 대신 재생 길이(초)를 보여준다 (끝까지면 영상 끝 - 시작). plLengthSec.value = Math.max(0, end - tctx.start).toFixed(2) } function renderPlayhead() { if (!tctx) return var d = tctx.duration || 0 var cur = 0 if (trimPlayer && trimPlayer.getCurrentTime) { try { cur = trimPlayer.getCurrentTime() || 0 } catch (_) { cur = 0 } } plTrimPlayhead.style.left = (d ? clampNum((cur / d) * 100, 0, 100) : 0) + '%' plTimeReadout.textContent = formatTimeF(cur) + ' / ' + formatTimeF(d) if (selStopAt != null && cur >= selStopAt - 0.05) { try { trimPlayer.pauseVideo() } catch (_) {} selStopAt = null } } function openTrim(idx) { var e = state.entries[idx] if (!e) return if (!e.ytId) { setRegisterStatus('이 항목은 YouTube 영상이 아니라 자르기를 지원하지 않습니다.', 'idStatusBad') return } trimTitle.textContent = e.title || '' trimOverlay.hidden = false // 항목의 시작 + 길이를 타임라인의 시작/끝으로 변환. 영상 길이를 넘으면 끝까지로. var startSec = e.startSec || 0 var lenSec = e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH var dur = e.durationSec || 0 var endSec = startSec + lenSec if (dur && endSec >= dur - 0.01) endSec = null tctx = { idx: idx, duration: dur, start: startSec, end: endSec } selStopAt = null renderTrimBar() renderPlayhead() whenYt(function () { if (trimOverlay.hidden) return // API 준비 전에 닫혔으면 무시 destroyTrimPlayer() resetHost(trimOverlay.querySelector('.ytEmbedWrap'), 'trimPlayer') trimPlayer = new YT.Player('trimPlayer', { videoId: e.ytId, playerVars: { rel: 0, modestbranding: 1, start: Math.floor(tctx.start || 0) }, events: { onReady: function () { var d = 0 try { d = trimPlayer.getDuration() } catch (_) {} if (d && isFinite(d) && d > 0) { tctx.duration = d tctx.start = clampNum(tctx.start, 0, Math.max(0, d - MIN_SEL)) if (tctx.end != null) tctx.end = clampNum(tctx.end, tctx.start + MIN_SEL, d) renderTrimBar() } startTrimPoll() } } }) }) } function startTrimPoll() { if (trimPoll) clearInterval(trimPoll) trimPoll = setInterval(renderPlayhead, 200) } function destroyTrimPlayer() { if (trimPoll) { clearInterval(trimPoll); trimPoll = null } if (trimPlayer) { try { trimPlayer.destroy() } catch (_) {} trimPlayer = null } } function closeTrim() { trimOverlay.hidden = true selStopAt = null destroyTrimPlayer() tctx = null } trimClose.addEventListener('click', closeTrim) trimCancel.addEventListener('click', closeTrim) trimOverlay.addEventListener('click', function (e) { if (e.target === trimOverlay) closeTrim() }) trimApply.addEventListener('click', function () { if (!tctx) return var e = state.entries[tctx.idx] if (e) { e.startSec = tctx.start > 0 ? tctx.start : 0 // 타임라인의 끝(없으면 영상 끝) - 시작 = 재생 길이. var end = tctx.end != null ? tctx.end : tctx.duration var len = end - e.startSec e.lengthSec = len > 0 ? len : DEFAULT_LENGTH if (tctx.duration && e.durationSec == null) e.durationSec = tctx.duration paintTrimInputs(tctx.idx) } closeTrim() }) // 타임라인 드래그/클릭/마크/숫자입력 function trimClientXtoTime(clientX) { var rect = plTrimBar.getBoundingClientRect() var ratio = clampNum((clientX - rect.left) / rect.width, 0, 1) return ratio * (tctx ? tctx.duration : 0) } function attachTrimHandle(handle, which) { handle.addEventListener('pointerdown', function (e) { if (!tctx || !tctx.duration) return e.preventDefault() handle.setPointerCapture(e.pointerId) handle.classList.add('dragging') function onMove(ev) { var t = trimClientXtoTime(ev.clientX) if (which === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL) else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration) renderTrimBar() } function onUp() { handle.classList.remove('dragging') handle.removeEventListener('pointermove', onMove) handle.removeEventListener('pointerup', onUp) handle.removeEventListener('pointercancel', onUp) } handle.addEventListener('pointermove', onMove) handle.addEventListener('pointerup', onUp) handle.addEventListener('pointercancel', onUp) }) } attachTrimHandle(plTrimHandleStart, 'start') attachTrimHandle(plTrimHandleEnd, 'end') plTrimBar.addEventListener('pointerdown', function (e) { if (!tctx || !tctx.duration) return if (e.target === plTrimHandleStart || e.target === plTrimHandleEnd) return var t = trimClientXtoTime(e.clientX) if (trimPlayer && trimPlayer.seekTo) { try { trimPlayer.seekTo(t, true) } catch (_) {} } renderPlayhead() }) trimOverlay.querySelectorAll('[data-pmark]').forEach(function (btn) { btn.addEventListener('click', function () { if (!tctx || !tctx.duration || !trimPlayer) return var t = 0 try { t = trimPlayer.getCurrentTime() || 0 } catch (_) {} if (btn.getAttribute('data-pmark') === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL) else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration) renderTrimBar() }) }) trimOverlay.querySelectorAll('[data-paction]').forEach(function (btn) { btn.addEventListener('click', function () { var action = btn.getAttribute('data-paction') if (action === 'reset') { if (!tctx) return tctx.start = 0; tctx.end = null renderTrimBar() } else if (action === 'playSelection') { if (!tctx || !trimPlayer) return try { trimPlayer.seekTo(tctx.start, true) trimPlayer.playVideo() } catch (_) {} selStopAt = tEnd() } }) }) plStartSec.addEventListener('change', function () { if (!tctx) return var v = Number(plStartSec.value || 0) if (!isFinite(v) || v < 0) v = 0 tctx.start = tctx.duration ? clampNum(v, 0, tEnd() - MIN_SEL) : v renderTrimBar() }) plLengthSec.addEventListener('change', function () { if (!tctx) return // 길이(초) 입력 → 끝 = 시작 + 길이. var v = Number(plLengthSec.value) if (!isFinite(v) || v <= 0) { renderTrimBar(); return } var end = tctx.start + v tctx.end = tctx.duration ? clampNum(end, tctx.start + MIN_SEL, tctx.duration) : end renderTrimBar() }) // ─── 임시저장(draft) ─────────────────────────────────────────────────── // 다운로드 시작 전, 수정해둔 값(URL + 항목별 제목/ID/시작/길이 등)을 폴더당 1개 저장/복원. var draftUrl = folderBaseUrl + '/playlist/draft' var draftDeleteUrl = folderBaseUrl + '/playlist/draft/delete' var draftSaveBtn = document.getElementById('draftSaveBtn') var draftLoadBtn = document.getElementById('draftLoadBtn') var draftDeleteBtn = document.getElementById('draftDeleteBtn') var draftStatus = document.getElementById('draftStatus') function setDraftStatus(text, cls) { if (!draftStatus) return draftStatus.textContent = text || '' draftStatus.className = cls || 'muted' } function formatSavedAt(iso) { try { var d = new Date(iso) if (isNaN(d.getTime())) return '' return d.toLocaleString() } catch (_) { return '' } } // 저장 대상: 편집 가능한 값만 추린다 (idStatus/idDebounce 같은 휘발 상태 제외). function serializeDraft() { return { url: (urlInput.value || '').trim(), idMode: state.idMode, zeroPad: state.zeroPad, entries: state.entries.map(function (e) { return { url: e.url, title: e.title || '', videoId: e.videoId || '', durationSec: e.durationSec != null ? e.durationSec : null, thumbnailUrl: e.thumbnailUrl || null, ytId: e.ytId || null, startSec: e.startSec || 0, lengthSec: e.lengthSec != null ? e.lengthSec : DEFAULT_LENGTH } }) } } // idMode 라디오 / zeroPad 토글을 상태에 맞춰 반영. function reflectIdModeControls() { for (var i = 0; i < idModeRadios.length; i++) { idModeRadios[i].checked = idModeRadios[i].value === state.idMode } zeroPadWrap.hidden = state.idMode !== 'sequential' zeroPadToggle.checked = !!state.zeroPad } function restoreDraft(d) { if (!d || typeof d !== 'object') return urlInput.value = d.url || '' state.idMode = d.idMode === 'sequential' ? 'sequential' : 'random' state.zeroPad = !!d.zeroPad var raw = Array.isArray(d.entries) ? d.entries : [] state.entries = raw.map(function (e) { return { url: e.url, title: e.title || '', videoId: (e.videoId || '').trim(), durationSec: e.durationSec != null ? e.durationSec : null, thumbnailUrl: e.thumbnailUrl || null, ytId: e.ytId || extractYouTubeId(e.url), startSec: e.startSec || 0, lengthSec: e.lengthSec != null && e.lengthSec > 0 ? e.lengthSec : DEFAULT_LENGTH, idStatus: 'pending', idReason: '' } }) reflectIdModeControls() render() entriesSection.hidden = state.entries.length === 0 // 저장된 ID 는 그대로 두고(자동 재생성 금지), 가용성만 다시 확인. for (var i = 0; i < state.entries.length; i++) scheduleIdCheck(i, 0) } function showDraftButtons(updatedAt) { if (draftLoadBtn) draftLoadBtn.hidden = false if (draftDeleteBtn) draftDeleteBtn.hidden = false setDraftStatus('임시저장됨: ' + formatSavedAt(updatedAt), 'muted') } function hideDraftButtons() { if (draftLoadBtn) draftLoadBtn.hidden = true if (draftDeleteBtn) draftDeleteBtn.hidden = true } if (draftSaveBtn) { draftSaveBtn.addEventListener('click', function () { draftSaveBtn.disabled = true setDraftStatus('저장 중…', 'muted') fetch(draftUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: serializeDraft() }) }) .then(function (r) { return r.json() }) .then(function (j) { if (j && j.ok) { showDraftButtons(j.updatedAt) setDraftStatus('임시저장 완료 (' + formatSavedAt(j.updatedAt) + ')', 'idStatusOk') } else { setDraftStatus((j && j.message) || '저장 실패', 'idStatusBad') } }) .catch(function (err) { setDraftStatus('저장 실패: ' + (err && err.message ? err.message : err), 'idStatusBad') }) .finally(function () { draftSaveBtn.disabled = false }) }) } if (draftLoadBtn) { draftLoadBtn.addEventListener('click', function () { if (state.entries.length > 0 && !window.confirm('현재 편집 중인 목록을 임시저장 내용으로 덮어쓸까요?')) { return } draftLoadBtn.disabled = true setDraftStatus('불러오는 중…', 'muted') fetch(draftUrl, { cache: 'no-store' }) .then(function (r) { return r.json() }) .then(function (j) { if (j && j.ok && j.draft) { var data try { data = JSON.parse(j.draft.data) } catch (_) { data = null } if (!data) { setDraftStatus('임시저장 데이터를 읽을 수 없습니다.', 'idStatusBad'); return } restoreDraft(data) setDraftStatus('임시저장 불러옴 (' + formatSavedAt(j.draft.updatedAt) + ')', 'idStatusOk') } else { hideDraftButtons() setDraftStatus('저장된 임시저장이 없습니다.', 'muted') } }) .catch(function (err) { setDraftStatus('불러오기 실패: ' + (err && err.message ? err.message : err), 'idStatusBad') }) .finally(function () { draftLoadBtn.disabled = false }) }) } if (draftDeleteBtn) { draftDeleteBtn.addEventListener('click', function () { if (!window.confirm('저장된 임시저장을 삭제할까요?')) return draftDeleteBtn.disabled = true fetch(draftDeleteUrl, { method: 'POST' }) .then(function (r) { return r.json() }) .then(function (j) { if (j && j.ok) { hideDraftButtons() setDraftStatus('임시저장을 삭제했습니다.', 'muted') } else { setDraftStatus((j && j.message) || '삭제 실패', 'idStatusBad') } }) .catch(function (err) { setDraftStatus('삭제 실패: ' + (err && err.message ? err.message : err), 'idStatusBad') }) .finally(function () { draftDeleteBtn.disabled = false }) }) } // 진입 시 기존 임시저장 여부 확인 → 있으면 불러오기/삭제 버튼 노출. fetch(draftUrl, { cache: 'no-store' }) .then(function (r) { return r.json() }) .then(function (j) { if (j && j.ok && j.draft) showDraftButtons(j.draft.updatedAt) }) .catch(function () {}) // 초기 상태 updateRegisterEnable() })()