feat(playlist): show thumbnails in import preview
플레이리스트 가져오기 미리보기 목록에 각 항목 썸네일을 표시한다. YouTube video id 로 mqdefault 썸네일 URL 을 만들고, id 가 없으면 yt-dlp 가 준 thumbnail 필드로 폴백. 깨진 이미지는 빈 박스 처리. - youtube.ts: PlaylistEntry 에 thumbnailUrl 추가 + flat-playlist 파싱에서 채움 - playlist.js: probe 매핑에 thumbnailUrl 반영, 각 행에 img 렌더(lazy + onerror) - styles.css: 행 그리드에 썸네일 칼럼(96px 16:9) 추가 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -85,7 +85,7 @@ data/
|
|||||||
- 영상 ID 인라인 편집 가능 (전체 영상에 걸쳐 고유, 안전한 ID 형태만 허용. 변경하면 공유 URL 의 마지막 세그먼트가 같이 바뀝니다).
|
- 영상 ID 인라인 편집 가능 (전체 영상에 걸쳐 고유, 안전한 ID 형태만 허용. 변경하면 공유 URL 의 마지막 세그먼트가 같이 바뀝니다).
|
||||||
- 오른쪽 위 "영상 추가" → `/op/folder/.../video/editor` 로 이동. 1단계·2단계 폴더 어디서나 영상을 추가할 수 있습니다 (1단계 폴더에는 "하위 폴더 추가" 버튼도 함께 노출).
|
- 오른쪽 위 "영상 추가" → `/op/folder/.../video/editor` 로 이동. 1단계·2단계 폴더 어디서나 영상을 추가할 수 있습니다 (1단계 폴더에는 "하위 폴더 추가" 버튼도 함께 노출).
|
||||||
- 2단계 폴더에서만 노출되는 "플레이리스트 추가" 버튼: YouTube 플레이리스트 URL 을 넣으면 항목을 미리 보여주고 시작할 수 있습니다.
|
- 2단계 폴더에서만 노출되는 "플레이리스트 추가" 버튼: YouTube 플레이리스트 URL 을 넣으면 항목을 미리 보여주고 시작할 수 있습니다.
|
||||||
- 항목별 **드래그 정렬** (정렬 UI 는 플레이리스트 임포트 화면 한정), 제목 / ID 인라인 편집.
|
- 항목별 **썸네일 미리보기**(YouTube `mqdefault`), **드래그 정렬** (정렬 UI 는 플레이리스트 임포트 화면 한정), 제목 / ID 인라인 편집.
|
||||||
- ID 정책 토글:
|
- ID 정책 토글:
|
||||||
- **랜덤** — 항목별로 24자 hex (crypto.randomUUID 압축, 폴백은 16진수 24자) 자동 생성.
|
- **랜덤** — 항목별로 24자 hex (crypto.randomUUID 압축, 폴백은 16진수 24자) 자동 생성.
|
||||||
- **시퀀셜** — 원래 플레이리스트 순서 기준 1, 2, 3, ... 으로 부여. zero-pad 옵션을 켜면 항목 총개수 자릿수에 맞춰 `001, 002, ...` 처럼 패딩.
|
- **시퀀셜** — 원래 플레이리스트 순서 기준 1, 2, 3, ... 으로 부여. zero-pad 옵션을 켜면 항목 총개수 자릿수에 맞춰 `001, 002, ...` 처럼 패딩.
|
||||||
|
|||||||
@@ -175,6 +175,7 @@
|
|||||||
li.innerHTML = ''
|
li.innerHTML = ''
|
||||||
+ '<span class="dragHandle" title="드래그로 순서 변경">≡</span>'
|
+ '<span class="dragHandle" title="드래그로 순서 변경">≡</span>'
|
||||||
+ '<span class="entryNum">' + (idx + 1) + '</span>'
|
+ '<span class="entryNum">' + (idx + 1) + '</span>'
|
||||||
|
+ '<div class="entryThumb"></div>'
|
||||||
+ '<div class="entryFields">'
|
+ '<div class="entryFields">'
|
||||||
+ '<input type="text" class="entryTitleInput" />'
|
+ '<input type="text" class="entryTitleInput" />'
|
||||||
+ '<div class="entryIdRow">'
|
+ '<div class="entryIdRow">'
|
||||||
@@ -185,11 +186,24 @@
|
|||||||
+ '<span class="entryDuration muted"></span>'
|
+ '<span class="entryDuration muted"></span>'
|
||||||
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
||||||
|
|
||||||
|
var thumbBox = li.querySelector('.entryThumb')
|
||||||
var titleInput = li.querySelector('.entryTitleInput')
|
var titleInput = li.querySelector('.entryTitleInput')
|
||||||
var idInput = li.querySelector('.entryIdInput')
|
var idInput = li.querySelector('.entryIdInput')
|
||||||
var durSpan = li.querySelector('.entryDuration')
|
var durSpan = li.querySelector('.entryDuration')
|
||||||
var removeBtn = li.querySelector('.entryRemove')
|
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 || ''
|
titleInput.value = entry.title || ''
|
||||||
idInput.value = entry.videoId || ''
|
idInput.value = entry.videoId || ''
|
||||||
durSpan.textContent = formatDuration(entry.durationSec)
|
durSpan.textContent = formatDuration(entry.durationSec)
|
||||||
@@ -364,6 +378,7 @@
|
|||||||
url: e.url,
|
url: e.url,
|
||||||
title: e.title || '제목 없음',
|
title: e.title || '제목 없음',
|
||||||
durationSec: e.durationSec,
|
durationSec: e.durationSec,
|
||||||
|
thumbnailUrl: e.thumbnailUrl || null,
|
||||||
videoId: '',
|
videoId: '',
|
||||||
idStatus: 'pending',
|
idStatus: 'pending',
|
||||||
idReason: ''
|
idReason: ''
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
}
|
}
|
||||||
.playlistEntryRow {
|
.playlistEntryRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 26px 36px 1fr 70px 28px;
|
grid-template-columns: 26px 36px 96px 1fr 70px 28px;
|
||||||
align-items: center; gap: 10px;
|
align-items: center; gap: 10px;
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: 10px; padding: 8px 10px;
|
border-radius: 10px; padding: 8px 10px;
|
||||||
@@ -367,6 +367,13 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
color: var(--text-muted); font-size: 13px; text-align: right;
|
color: var(--text-muted); font-size: 13px; text-align: right;
|
||||||
}
|
}
|
||||||
|
.entryThumb {
|
||||||
|
width: 96px; aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 6px; overflow: hidden;
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.entryThumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.entryThumbEmpty { opacity: 0.6; }
|
||||||
.entryFields {
|
.entryFields {
|
||||||
display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export interface PlaylistEntry {
|
|||||||
title: string
|
title: string
|
||||||
/** 메타에 없으면 null. UI 표시용. */
|
/** 메타에 없으면 null. UI 표시용. */
|
||||||
durationSec: number | null
|
durationSec: number | null
|
||||||
|
/** 미리보기용 썸네일 URL. 못 구하면 null. */
|
||||||
|
thumbnailUrl: string | null
|
||||||
/** 1 부터 시작하는 원래 플레이리스트 위치 — 시퀀셜 ID zero-pad 자릿수 계산용. */
|
/** 1 부터 시작하는 원래 플레이리스트 위치 — 시퀀셜 ID zero-pad 자릿수 계산용. */
|
||||||
originalIndex: number
|
originalIndex: number
|
||||||
}
|
}
|
||||||
@@ -157,7 +159,14 @@ export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeRe
|
|||||||
const dur = obj.duration
|
const dur = obj.duration
|
||||||
const durationSec =
|
const durationSec =
|
||||||
typeof dur === 'number' && Number.isFinite(dur) ? Math.round(dur) : null
|
typeof dur === 'number' && Number.isFinite(dur) ? Math.round(dur) : null
|
||||||
entries.push({ url: entryUrl, title, durationSec, originalIndex: idx })
|
// 썸네일: YouTube video id 가 있으면 표준 mqdefault URL 로 만든다(목록용 320x180).
|
||||||
|
// id 가 없으면 yt-dlp 가 준 thumbnail 필드를 폴백으로 사용.
|
||||||
|
const thumbnailUrl = id
|
||||||
|
? `https://i.ytimg.com/vi/${id}/mqdefault.jpg`
|
||||||
|
: typeof obj.thumbnail === 'string' && obj.thumbnail
|
||||||
|
? obj.thumbnail
|
||||||
|
: null
|
||||||
|
entries.push({ url: entryUrl, title, durationSec, thumbnailUrl, originalIndex: idx })
|
||||||
}
|
}
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
reject(new Error('플레이리스트에서 항목을 찾지 못했습니다.'))
|
reject(new Error('플레이리스트에서 항목을 찾지 못했습니다.'))
|
||||||
|
|||||||
Reference in New Issue
Block a user