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:
Claude (owner)
2026-06-03 21:29:58 +09:00
parent 7105349a45
commit 2f916fe958
2 changed files with 52 additions and 41 deletions

View File

@@ -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)
}

View File

@@ -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>