feat(playlist): per-entry/bulk trim, preview, and trim editor on import
플레이리스트 가져오기 화면에 다음을 추가: - 항목별 제목 입력을 절반으로 줄이고 오른쪽에 시작/종료 시간 입력 추가 (기본 시작 0, 종료는 영상 길이) - 정렬 옵션 옆에 일괄 시작/종료 입력과 적용/초기화 버튼 추가 (초기화 = 시작 0, 종료 = 영상 길이) - 항목 썸네일 클릭 시 YouTube 임베드로 미리보기 재생 - 항목 우클릭 → 자르기 메뉴 → /video/editor 식 타임라인 모달(YouTube IFrame) 에서 구간을 정하면 항목의 시작/종료 입력에 반영 - 백엔드: playlist/start 가 항목별 startSec/endSec 를 받아 다운로드 잡에 실어 보내고, 60fps 후처리 후 ffmpeg 로 해당 구간을 잘라 편집본 생성 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,9 @@
|
||||
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
||||
|
||||
// ─── state ────────────────────────────────────────────────────────────
|
||||
// entries: [{ url, title, videoId, durationSec, idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
|
||||
// entries: [{ url, title, videoId, durationSec, thumbnailUrl, ytId,
|
||||
// startSec:number(기본0), endSec:number|null(null=끝까지),
|
||||
// idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
|
||||
var state = {
|
||||
entries: [],
|
||||
idMode: 'random', // 'random' | 'sequential'
|
||||
@@ -175,7 +177,7 @@
|
||||
li.innerHTML = ''
|
||||
+ '<span class="dragHandle" title="드래그로 순서 변경">≡</span>'
|
||||
+ '<span class="entryNum">' + (idx + 1) + '</span>'
|
||||
+ '<div class="entryThumb"></div>'
|
||||
+ '<div class="entryThumb" title="클릭하면 재생"></div>'
|
||||
+ '<div class="entryFields">'
|
||||
+ '<input type="text" class="entryTitleInput" />'
|
||||
+ '<div class="entryIdRow">'
|
||||
@@ -183,12 +185,18 @@
|
||||
+ '<span class="entryIdStatus muted"></span>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="entryTrim">'
|
||||
+ '<label>시작 <input type="text" class="entryStartInput" autocomplete="off" /></label>'
|
||||
+ '<label>종료 <input type="text" class="entryEndInput" autocomplete="off" /></label>'
|
||||
+ '</div>'
|
||||
+ '<span class="entryDuration muted"></span>'
|
||||
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
||||
|
||||
var thumbBox = li.querySelector('.entryThumb')
|
||||
var titleInput = li.querySelector('.entryTitleInput')
|
||||
var idInput = li.querySelector('.entryIdInput')
|
||||
var startInput = li.querySelector('.entryStartInput')
|
||||
var endInput = li.querySelector('.entryEndInput')
|
||||
var durSpan = li.querySelector('.entryDuration')
|
||||
var removeBtn = li.querySelector('.entryRemove')
|
||||
|
||||
@@ -207,6 +215,7 @@
|
||||
titleInput.value = entry.title || ''
|
||||
idInput.value = entry.videoId || ''
|
||||
durSpan.textContent = formatDuration(entry.durationSec)
|
||||
paintTrimInputs(idx, startInput, endInput)
|
||||
|
||||
titleInput.addEventListener('input', function () {
|
||||
state.entries[idx].title = titleInput.value
|
||||
@@ -217,6 +226,28 @@
|
||||
// 요청 내 중복은 다른 행에도 영향 → 가시적으로 갱신
|
||||
revalidateOtherIds(idx)
|
||||
})
|
||||
startInput.addEventListener('change', function () {
|
||||
var e = state.entries[idx]
|
||||
var sec = parseClock(startInput.value)
|
||||
e.startSec = sec != null && sec > 0 ? sec : 0
|
||||
// 시작이 종료 이상이면 종료를 풀어 끝까지로.
|
||||
if (e.endSec != null && e.endSec <= e.startSec) e.endSec = null
|
||||
paintTrimInputs(idx, startInput, endInput)
|
||||
})
|
||||
endInput.addEventListener('change', function () {
|
||||
var e = state.entries[idx]
|
||||
var sec = parseClock(endInput.value)
|
||||
e.endSec = normalizeEnd(sec, e.startSec, e.durationSec)
|
||||
paintTrimInputs(idx, startInput, endInput)
|
||||
})
|
||||
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 이면 번호 재계산
|
||||
@@ -229,6 +260,34 @@
|
||||
return li
|
||||
}
|
||||
|
||||
// 항목의 시작/종료 입력칸 값을 상태로부터 다시 그린다.
|
||||
// 종료는 endSec 이 null 이면 영상 길이(기본값)를 표시한다.
|
||||
function paintTrimInputs(idx, startInput, endInput) {
|
||||
var e = state.entries[idx]
|
||||
if (!e) return
|
||||
if (!startInput) {
|
||||
var row = findRow(idx)
|
||||
if (!row) return
|
||||
startInput = row.querySelector('.entryStartInput')
|
||||
endInput = row.querySelector('.entryEndInput')
|
||||
if (!startInput || !endInput) return
|
||||
}
|
||||
startInput.value = formatClock(e.startSec || 0)
|
||||
if (e.endSec != null) {
|
||||
endInput.value = formatClock(e.endSec)
|
||||
} else {
|
||||
endInput.value = e.durationSec != null ? formatClock(e.durationSec) : ''
|
||||
}
|
||||
}
|
||||
|
||||
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++) {
|
||||
@@ -379,7 +438,10 @@
|
||||
title: e.title || '제목 없음',
|
||||
durationSec: e.durationSec,
|
||||
thumbnailUrl: e.thumbnailUrl || null,
|
||||
ytId: extractYouTubeId(e.url),
|
||||
videoId: '',
|
||||
startSec: 0,
|
||||
endSec: null,
|
||||
idStatus: 'pending',
|
||||
idReason: ''
|
||||
}
|
||||
@@ -405,7 +467,9 @@
|
||||
return {
|
||||
url: e.url,
|
||||
title: (e.title || '').trim(),
|
||||
videoId: (e.videoId || '').trim()
|
||||
videoId: (e.videoId || '').trim(),
|
||||
startSec: e.startSec || 0,
|
||||
endSec: e.endSec != null ? e.endSec : null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -441,6 +505,370 @@
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 시간 파싱/표시 + 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
|
||||
}
|
||||
// 종료초 정규화: null/오류/시작이하/영상끝 이상 → null(끝까지), 그 외 number.
|
||||
function normalizeEnd(sec, startSec, durationSec) {
|
||||
if (sec == null || !isFinite(sec)) return null
|
||||
if (sec <= (startSec || 0)) return null
|
||||
if (durationSec != null && sec >= durationSec - 0.01) return null
|
||||
return sec
|
||||
}
|
||||
function clampNum(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) }
|
||||
|
||||
// ─── 일괄 시작/종료 적용 + 초기화 ──────────────────────────────────────
|
||||
var bulkStart = document.getElementById('bulkStart')
|
||||
var bulkEnd = document.getElementById('bulkEnd')
|
||||
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 endRaw = parseClock(bulkEnd.value) // 빈칸이면 null → 끝까지
|
||||
for (var i = 0; i < state.entries.length; i++) {
|
||||
var e = state.entries[i]
|
||||
e.startSec = startSec
|
||||
e.endSec = normalizeEnd(endRaw, startSec, e.durationSec)
|
||||
}
|
||||
render()
|
||||
})
|
||||
bulkResetBtn.addEventListener('click', function () {
|
||||
for (var i = 0; i < state.entries.length; i++) {
|
||||
state.entries[i].startSec = 0
|
||||
state.entries[i].endSec = null // 끝까지(=영상 길이)
|
||||
}
|
||||
bulkStart.value = ''
|
||||
bulkEnd.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 = '<div id="' + id + '"></div>'
|
||||
}
|
||||
|
||||
// ─── 미리보기 재생 모달 ────────────────────────────────────────────────
|
||||
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 plEndSec = document.getElementById('plEndSec')
|
||||
|
||||
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)
|
||||
plEndSec.value = tctx.end == null ? '' : tctx.end.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
|
||||
tctx = {
|
||||
idx: idx,
|
||||
duration: e.durationSec || 0,
|
||||
start: e.startSec || 0,
|
||||
end: e.endSec != null ? e.endSec : null
|
||||
}
|
||||
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
|
||||
e.endSec = normalizeEnd(tctx.end, e.startSec, tctx.duration)
|
||||
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()
|
||||
})
|
||||
plEndSec.addEventListener('change', function () {
|
||||
if (!tctx) return
|
||||
if (plEndSec.value === '') {
|
||||
tctx.end = null
|
||||
} else {
|
||||
var v = Number(plEndSec.value)
|
||||
if (!isFinite(v) || v <= tctx.start) { renderTrimBar(); return }
|
||||
tctx.end = tctx.duration ? clampNum(v, tctx.start + MIN_SEL, tctx.duration) : v
|
||||
}
|
||||
renderTrimBar()
|
||||
})
|
||||
|
||||
// 초기 상태
|
||||
updateRegisterEnable()
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user