feat(playlist): replace end-time inputs with play-length (default 15s)
Per-entry and bulk trim inputs now take a play length in seconds (start + length) instead of an end time. Default 15s, so start=30 produces a 30–45s clip. Converted to endSec at register time; the trim editor maps its end handle to a length on apply. Backend unchanged (still works in start/end terms). Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -17,10 +17,11 @@
|
||||
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), endSec:number|null(null=끝까지),
|
||||
// startSec:number(기본0), lengthSec:number(기본15, 시작부터 재생할 초),
|
||||
// idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
|
||||
var state = {
|
||||
entries: [],
|
||||
@@ -187,7 +188,7 @@
|
||||
+ '</div>'
|
||||
+ '<div class="entryTrim">'
|
||||
+ '<label>시작 <input type="text" class="entryStartInput" autocomplete="off" /></label>'
|
||||
+ '<label>종료 <input type="text" class="entryEndInput" autocomplete="off" /></label>'
|
||||
+ '<label>길이(초) <input type="text" class="entryLengthInput" autocomplete="off" /></label>'
|
||||
+ '</div>'
|
||||
+ '<span class="entryDuration muted"></span>'
|
||||
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
||||
@@ -196,7 +197,7 @@
|
||||
var titleInput = li.querySelector('.entryTitleInput')
|
||||
var idInput = li.querySelector('.entryIdInput')
|
||||
var startInput = li.querySelector('.entryStartInput')
|
||||
var endInput = li.querySelector('.entryEndInput')
|
||||
var lengthInput = li.querySelector('.entryLengthInput')
|
||||
var durSpan = li.querySelector('.entryDuration')
|
||||
var removeBtn = li.querySelector('.entryRemove')
|
||||
|
||||
@@ -215,7 +216,7 @@
|
||||
titleInput.value = entry.title || ''
|
||||
idInput.value = entry.videoId || ''
|
||||
durSpan.textContent = formatDuration(entry.durationSec)
|
||||
paintTrimInputs(idx, startInput, endInput)
|
||||
paintTrimInputs(idx, startInput, lengthInput)
|
||||
|
||||
titleInput.addEventListener('input', function () {
|
||||
state.entries[idx].title = titleInput.value
|
||||
@@ -230,15 +231,13 @@
|
||||
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)
|
||||
paintTrimInputs(idx, startInput, lengthInput)
|
||||
})
|
||||
endInput.addEventListener('change', function () {
|
||||
lengthInput.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)
|
||||
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) {
|
||||
@@ -260,24 +259,19 @@
|
||||
return li
|
||||
}
|
||||
|
||||
// 항목의 시작/종료 입력칸 값을 상태로부터 다시 그린다.
|
||||
// 종료는 endSec 이 null 이면 영상 길이(기본값)를 표시한다.
|
||||
function paintTrimInputs(idx, startInput, endInput) {
|
||||
// 항목의 시작/길이 입력칸 값을 상태로부터 다시 그린다.
|
||||
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')
|
||||
endInput = row.querySelector('.entryEndInput')
|
||||
if (!startInput || !endInput) return
|
||||
lengthInput = row.querySelector('.entryLengthInput')
|
||||
if (!startInput || !lengthInput) 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) : ''
|
||||
}
|
||||
lengthInput.value = formatLength(e.lengthSec)
|
||||
}
|
||||
|
||||
function findRow(idx) {
|
||||
@@ -441,7 +435,7 @@
|
||||
ytId: extractYouTubeId(e.url),
|
||||
videoId: '',
|
||||
startSec: 0,
|
||||
endSec: null,
|
||||
lengthSec: DEFAULT_LENGTH,
|
||||
idStatus: 'pending',
|
||||
idReason: ''
|
||||
}
|
||||
@@ -469,7 +463,7 @@
|
||||
title: (e.title || '').trim(),
|
||||
videoId: (e.videoId || '').trim(),
|
||||
startSec: e.startSec || 0,
|
||||
endSec: e.endSec != null ? e.endSec : null
|
||||
endSec: computeEndSec(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -538,39 +532,47 @@
|
||||
)
|
||||
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
|
||||
// 재생 길이(초) 표시용. 정수면 "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 bulkEnd = document.getElementById('bulkEnd')
|
||||
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 endRaw = parseClock(bulkEnd.value) // 빈칸이면 null → 끝까지
|
||||
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.endSec = normalizeEnd(endRaw, startSec, e.durationSec)
|
||||
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].endSec = null // 끝까지(=영상 길이)
|
||||
state.entries[i].lengthSec = DEFAULT_LENGTH
|
||||
}
|
||||
bulkStart.value = ''
|
||||
bulkEnd.value = ''
|
||||
bulkLength.value = ''
|
||||
render()
|
||||
})
|
||||
|
||||
@@ -722,11 +724,17 @@
|
||||
}
|
||||
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: e.durationSec || 0,
|
||||
start: e.startSec || 0,
|
||||
end: e.endSec != null ? e.endSec : null
|
||||
duration: dur,
|
||||
start: startSec,
|
||||
end: endSec
|
||||
}
|
||||
selStopAt = null
|
||||
renderTrimBar()
|
||||
@@ -778,7 +786,10 @@
|
||||
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)
|
||||
// 타임라인의 끝(없으면 영상 끝) - 시작 = 재생 길이.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<div class="bulkTrimBox">
|
||||
<span class="idModeLabel">일괄 구간</span>
|
||||
<label>시작 <input type="text" id="bulkStart" placeholder="0:00" /></label>
|
||||
<label>종료 <input type="text" id="bulkEnd" placeholder="끝까지" /></label>
|
||||
<label>길이(초) <input type="text" id="bulkLength" placeholder="15" /></label>
|
||||
<button type="button" class="secondaryButton" id="bulkApplyBtn">적용</button>
|
||||
<button type="button" class="secondaryButton" id="bulkResetBtn">초기화</button>
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@
|
||||
<span class="trimDuration" id="plTrimDuration">선택: 0.0초</span>
|
||||
<label>끝(초, 비우면 끝까지) <input type="number" id="plEndSec" step="0.1" min="0" value="" /></label>
|
||||
</div>
|
||||
<p class="muted">재생바의 파란 핸들을 끌어 구간을 정합니다. 적용하면 항목의 시작/종료 시간이 갱신되고, 다운로드 완료 후 이 구간으로 잘립니다.</p>
|
||||
<p class="muted">재생바의 파란 핸들을 끌어 구간을 정합니다. 적용하면 항목의 시작/길이가 갱신되고, 다운로드 완료 후 이 구간으로 잘립니다.</p>
|
||||
</div>
|
||||
<div class="modalActions">
|
||||
<button type="button" class="secondaryButton" id="trimCancel">취소</button>
|
||||
|
||||
Reference in New Issue
Block a user