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 startUrl = folderBaseUrl + '/playlist/start'
|
||||||
|
|
||||||
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
||||||
|
var DEFAULT_LENGTH = 15 // 기본 재생 길이(초)
|
||||||
|
|
||||||
// ─── state ────────────────────────────────────────────────────────────
|
// ─── state ────────────────────────────────────────────────────────────
|
||||||
// entries: [{ url, title, videoId, durationSec, thumbnailUrl, ytId,
|
// 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 }]
|
// idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
|
||||||
var state = {
|
var state = {
|
||||||
entries: [],
|
entries: [],
|
||||||
@@ -187,7 +188,7 @@
|
|||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="entryTrim">'
|
+ '<div class="entryTrim">'
|
||||||
+ '<label>시작 <input type="text" class="entryStartInput" autocomplete="off" /></label>'
|
+ '<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>'
|
+ '</div>'
|
||||||
+ '<span class="entryDuration muted"></span>'
|
+ '<span class="entryDuration muted"></span>'
|
||||||
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
||||||
@@ -196,7 +197,7 @@
|
|||||||
var titleInput = li.querySelector('.entryTitleInput')
|
var titleInput = li.querySelector('.entryTitleInput')
|
||||||
var idInput = li.querySelector('.entryIdInput')
|
var idInput = li.querySelector('.entryIdInput')
|
||||||
var startInput = li.querySelector('.entryStartInput')
|
var startInput = li.querySelector('.entryStartInput')
|
||||||
var endInput = li.querySelector('.entryEndInput')
|
var lengthInput = li.querySelector('.entryLengthInput')
|
||||||
var durSpan = li.querySelector('.entryDuration')
|
var durSpan = li.querySelector('.entryDuration')
|
||||||
var removeBtn = li.querySelector('.entryRemove')
|
var removeBtn = li.querySelector('.entryRemove')
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@
|
|||||||
titleInput.value = entry.title || ''
|
titleInput.value = entry.title || ''
|
||||||
idInput.value = entry.videoId || ''
|
idInput.value = entry.videoId || ''
|
||||||
durSpan.textContent = formatDuration(entry.durationSec)
|
durSpan.textContent = formatDuration(entry.durationSec)
|
||||||
paintTrimInputs(idx, startInput, endInput)
|
paintTrimInputs(idx, startInput, lengthInput)
|
||||||
|
|
||||||
titleInput.addEventListener('input', function () {
|
titleInput.addEventListener('input', function () {
|
||||||
state.entries[idx].title = titleInput.value
|
state.entries[idx].title = titleInput.value
|
||||||
@@ -230,15 +231,13 @@
|
|||||||
var e = state.entries[idx]
|
var e = state.entries[idx]
|
||||||
var sec = parseClock(startInput.value)
|
var sec = parseClock(startInput.value)
|
||||||
e.startSec = sec != null && sec > 0 ? sec : 0
|
e.startSec = sec != null && sec > 0 ? sec : 0
|
||||||
// 시작이 종료 이상이면 종료를 풀어 끝까지로.
|
paintTrimInputs(idx, startInput, lengthInput)
|
||||||
if (e.endSec != null && e.endSec <= e.startSec) e.endSec = null
|
|
||||||
paintTrimInputs(idx, startInput, endInput)
|
|
||||||
})
|
})
|
||||||
endInput.addEventListener('change', function () {
|
lengthInput.addEventListener('change', function () {
|
||||||
var e = state.entries[idx]
|
var e = state.entries[idx]
|
||||||
var sec = parseClock(endInput.value)
|
var sec = parseClock(lengthInput.value)
|
||||||
e.endSec = normalizeEnd(sec, e.startSec, e.durationSec)
|
e.lengthSec = sec != null && sec > 0 ? sec : DEFAULT_LENGTH
|
||||||
paintTrimInputs(idx, startInput, endInput)
|
paintTrimInputs(idx, startInput, lengthInput)
|
||||||
})
|
})
|
||||||
thumbBox.addEventListener('click', function () { openPreview(idx) })
|
thumbBox.addEventListener('click', function () { openPreview(idx) })
|
||||||
li.addEventListener('contextmenu', function (ev) {
|
li.addEventListener('contextmenu', function (ev) {
|
||||||
@@ -260,24 +259,19 @@
|
|||||||
return li
|
return li
|
||||||
}
|
}
|
||||||
|
|
||||||
// 항목의 시작/종료 입력칸 값을 상태로부터 다시 그린다.
|
// 항목의 시작/길이 입력칸 값을 상태로부터 다시 그린다.
|
||||||
// 종료는 endSec 이 null 이면 영상 길이(기본값)를 표시한다.
|
function paintTrimInputs(idx, startInput, lengthInput) {
|
||||||
function paintTrimInputs(idx, startInput, endInput) {
|
|
||||||
var e = state.entries[idx]
|
var e = state.entries[idx]
|
||||||
if (!e) return
|
if (!e) return
|
||||||
if (!startInput) {
|
if (!startInput) {
|
||||||
var row = findRow(idx)
|
var row = findRow(idx)
|
||||||
if (!row) return
|
if (!row) return
|
||||||
startInput = row.querySelector('.entryStartInput')
|
startInput = row.querySelector('.entryStartInput')
|
||||||
endInput = row.querySelector('.entryEndInput')
|
lengthInput = row.querySelector('.entryLengthInput')
|
||||||
if (!startInput || !endInput) return
|
if (!startInput || !lengthInput) return
|
||||||
}
|
}
|
||||||
startInput.value = formatClock(e.startSec || 0)
|
startInput.value = formatClock(e.startSec || 0)
|
||||||
if (e.endSec != null) {
|
lengthInput.value = formatLength(e.lengthSec)
|
||||||
endInput.value = formatClock(e.endSec)
|
|
||||||
} else {
|
|
||||||
endInput.value = e.durationSec != null ? formatClock(e.durationSec) : ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findRow(idx) {
|
function findRow(idx) {
|
||||||
@@ -441,7 +435,7 @@
|
|||||||
ytId: extractYouTubeId(e.url),
|
ytId: extractYouTubeId(e.url),
|
||||||
videoId: '',
|
videoId: '',
|
||||||
startSec: 0,
|
startSec: 0,
|
||||||
endSec: null,
|
lengthSec: DEFAULT_LENGTH,
|
||||||
idStatus: 'pending',
|
idStatus: 'pending',
|
||||||
idReason: ''
|
idReason: ''
|
||||||
}
|
}
|
||||||
@@ -469,7 +463,7 @@
|
|||||||
title: (e.title || '').trim(),
|
title: (e.title || '').trim(),
|
||||||
videoId: (e.videoId || '').trim(),
|
videoId: (e.videoId || '').trim(),
|
||||||
startSec: e.startSec || 0,
|
startSec: e.startSec || 0,
|
||||||
endSec: e.endSec != null ? e.endSec : null
|
endSec: computeEndSec(e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -538,39 +532,47 @@
|
|||||||
)
|
)
|
||||||
return m ? m[1] : null
|
return m ? m[1] : null
|
||||||
}
|
}
|
||||||
// 종료초 정규화: null/오류/시작이하/영상끝 이상 → null(끝까지), 그 외 number.
|
// 재생 길이(초) 표시용. 정수면 "15", 소수면 "12.3".
|
||||||
function normalizeEnd(sec, startSec, durationSec) {
|
function formatLength(sec) {
|
||||||
if (sec == null || !isFinite(sec)) return null
|
if (sec == null || !isFinite(sec) || sec <= 0) return ''
|
||||||
if (sec <= (startSec || 0)) return null
|
var r = Math.round(sec * 10) / 10
|
||||||
if (durationSec != null && sec >= durationSec - 0.01) return null
|
return r % 1 === 0 ? String(r) : r.toFixed(1)
|
||||||
return sec
|
}
|
||||||
|
// 시작 + 길이 → 백엔드용 종료초. 영상 끝을 넘으면 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)) }
|
function clampNum(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) }
|
||||||
|
|
||||||
// ─── 일괄 시작/종료 적용 + 초기화 ──────────────────────────────────────
|
// ─── 일괄 시작/길이 적용 + 초기화 ──────────────────────────────────────
|
||||||
var bulkStart = document.getElementById('bulkStart')
|
var bulkStart = document.getElementById('bulkStart')
|
||||||
var bulkEnd = document.getElementById('bulkEnd')
|
var bulkLength = document.getElementById('bulkLength')
|
||||||
var bulkApplyBtn = document.getElementById('bulkApplyBtn')
|
var bulkApplyBtn = document.getElementById('bulkApplyBtn')
|
||||||
var bulkResetBtn = document.getElementById('bulkResetBtn')
|
var bulkResetBtn = document.getElementById('bulkResetBtn')
|
||||||
|
|
||||||
bulkApplyBtn.addEventListener('click', function () {
|
bulkApplyBtn.addEventListener('click', function () {
|
||||||
var s = parseClock(bulkStart.value)
|
var s = parseClock(bulkStart.value)
|
||||||
var startSec = s != null && s > 0 ? s : 0
|
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++) {
|
for (var i = 0; i < state.entries.length; i++) {
|
||||||
var e = state.entries[i]
|
var e = state.entries[i]
|
||||||
e.startSec = startSec
|
e.startSec = startSec
|
||||||
e.endSec = normalizeEnd(endRaw, startSec, e.durationSec)
|
e.lengthSec = lengthSec
|
||||||
}
|
}
|
||||||
render()
|
render()
|
||||||
})
|
})
|
||||||
bulkResetBtn.addEventListener('click', function () {
|
bulkResetBtn.addEventListener('click', function () {
|
||||||
for (var i = 0; i < state.entries.length; i++) {
|
for (var i = 0; i < state.entries.length; i++) {
|
||||||
state.entries[i].startSec = 0
|
state.entries[i].startSec = 0
|
||||||
state.entries[i].endSec = null // 끝까지(=영상 길이)
|
state.entries[i].lengthSec = DEFAULT_LENGTH
|
||||||
}
|
}
|
||||||
bulkStart.value = ''
|
bulkStart.value = ''
|
||||||
bulkEnd.value = ''
|
bulkLength.value = ''
|
||||||
render()
|
render()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -722,11 +724,17 @@
|
|||||||
}
|
}
|
||||||
trimTitle.textContent = e.title || ''
|
trimTitle.textContent = e.title || ''
|
||||||
trimOverlay.hidden = false
|
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 = {
|
tctx = {
|
||||||
idx: idx,
|
idx: idx,
|
||||||
duration: e.durationSec || 0,
|
duration: dur,
|
||||||
start: e.startSec || 0,
|
start: startSec,
|
||||||
end: e.endSec != null ? e.endSec : null
|
end: endSec
|
||||||
}
|
}
|
||||||
selStopAt = null
|
selStopAt = null
|
||||||
renderTrimBar()
|
renderTrimBar()
|
||||||
@@ -778,7 +786,10 @@
|
|||||||
var e = state.entries[tctx.idx]
|
var e = state.entries[tctx.idx]
|
||||||
if (e) {
|
if (e) {
|
||||||
e.startSec = tctx.start > 0 ? tctx.start : 0
|
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
|
if (tctx.duration && e.durationSec == null) e.durationSec = tctx.duration
|
||||||
paintTrimInputs(tctx.idx)
|
paintTrimInputs(tctx.idx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
<div class="bulkTrimBox">
|
<div class="bulkTrimBox">
|
||||||
<span class="idModeLabel">일괄 구간</span>
|
<span class="idModeLabel">일괄 구간</span>
|
||||||
<label>시작 <input type="text" id="bulkStart" placeholder="0:00" /></label>
|
<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="bulkApplyBtn">적용</button>
|
||||||
<button type="button" class="secondaryButton" id="bulkResetBtn">초기화</button>
|
<button type="button" class="secondaryButton" id="bulkResetBtn">초기화</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
<span class="trimDuration" id="plTrimDuration">선택: 0.0초</span>
|
<span class="trimDuration" id="plTrimDuration">선택: 0.0초</span>
|
||||||
<label>끝(초, 비우면 끝까지) <input type="number" id="plEndSec" step="0.1" min="0" value="" /></label>
|
<label>끝(초, 비우면 끝까지) <input type="number" id="plEndSec" step="0.1" min="0" value="" /></label>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">재생바의 파란 핸들을 끌어 구간을 정합니다. 적용하면 항목의 시작/종료 시간이 갱신되고, 다운로드 완료 후 이 구간으로 잘립니다.</p>
|
<p class="muted">재생바의 파란 핸들을 끌어 구간을 정합니다. 적용하면 항목의 시작/길이가 갱신되고, 다운로드 완료 후 이 구간으로 잘립니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modalActions">
|
<div class="modalActions">
|
||||||
<button type="button" class="secondaryButton" id="trimCancel">취소</button>
|
<button type="button" class="secondaryButton" id="trimCancel">취소</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user