2103 lines
72 KiB
HTML
2103 lines
72 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Emby Thumbnail Generator</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #0d0f12;
|
||
--surface: #13171f;
|
||
--surface2: #1a2030;
|
||
--surface3: #222840;
|
||
--border: #252d3d;
|
||
--border-active: #4a6fff;
|
||
--text: #e8ecf4;
|
||
--text-2: #8892a8;
|
||
--text-3: #4e5a70;
|
||
--accent: #4a6fff;
|
||
--accent-h: #6384ff;
|
||
--accent-glow: rgba(74,111,255,0.12);
|
||
--green: #34d399;
|
||
--green-bg: rgba(52,211,153,0.08);
|
||
--red: #f87171;
|
||
--r: 10px;
|
||
--r-lg: 14px;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: 'DM Sans', sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
min-height: 100svh;
|
||
overflow: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* ── Header ── */
|
||
.header {
|
||
height: 56px;
|
||
padding: 0 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--surface);
|
||
flex-shrink: 0;
|
||
}
|
||
.header-logo {
|
||
width: 30px; height: 30px;
|
||
background: var(--accent);
|
||
border-radius: 7px;
|
||
display: grid; place-items: center;
|
||
font-weight: 700; font-size: 12px; color: #fff;
|
||
flex-shrink: 0;
|
||
}
|
||
.header h1 { font-size: 15px; font-weight: 600; letter-spacing: -0.02em; }
|
||
.header-nav {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-left: 10px;
|
||
}
|
||
.nav-link {
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--border);
|
||
color: var(--text-2);
|
||
text-decoration: none;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||
}
|
||
.nav-link:hover {
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
}
|
||
.nav-link.active {
|
||
background: var(--accent-glow);
|
||
border-color: var(--accent);
|
||
color: var(--text);
|
||
}
|
||
.header-status {
|
||
margin-left: auto;
|
||
display: flex; align-items: center; gap: 7px;
|
||
font-size: 12px; color: var(--text-2);
|
||
}
|
||
.dot {
|
||
width: 7px; height: 7px; border-radius: 50%;
|
||
background: var(--green); box-shadow: 0 0 6px var(--green);
|
||
}
|
||
.dot.off { background: var(--red); box-shadow: 0 0 6px var(--red); }
|
||
|
||
/* ── Body ── */
|
||
.body {
|
||
flex: 1;
|
||
display: grid;
|
||
grid-template-columns: 320px 1fr;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ── Sidebar ── */
|
||
.sidebar {
|
||
border-right: 1px solid var(--border);
|
||
background: var(--surface);
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.search-wrap {
|
||
padding: 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
.search-inner { position: relative; }
|
||
.search-inner svg {
|
||
position: absolute; left: 11px; top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--text-3); pointer-events: none;
|
||
}
|
||
.search-input {
|
||
width: 100%;
|
||
padding: 9px 12px 9px 36px;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--r);
|
||
color: var(--text);
|
||
font-family: inherit; font-size: 13px;
|
||
outline: none;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
.search-input::placeholder { color: var(--text-3); }
|
||
.search-input:focus {
|
||
border-color: var(--border-active);
|
||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||
}
|
||
|
||
.results {
|
||
flex: 1; min-height: 0; overflow-y: auto; padding: 6px;
|
||
}
|
||
.results::-webkit-scrollbar { width: 4px; }
|
||
.results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||
.results-toolbar,
|
||
.results-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
background: var(--surface);
|
||
flex-shrink: 0;
|
||
}
|
||
.results-toolbar {
|
||
min-height: 38px;
|
||
border-bottom: 1px solid var(--border);
|
||
justify-content: space-between;
|
||
}
|
||
.results-count,
|
||
.results-page {
|
||
font-size: 11px;
|
||
color: var(--text-2);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
}
|
||
.results-footer {
|
||
justify-content: flex-end;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.pager-btn {
|
||
min-width: 84px;
|
||
padding: 7px 10px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||
}
|
||
.pager-btn:hover:not(:disabled) { background: var(--surface3); }
|
||
.pager-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
|
||
.result-item {
|
||
display: flex; align-items: center; gap: 11px;
|
||
padding: 9px 10px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
transition: background 0.12s, border-color 0.12s;
|
||
}
|
||
.result-item:hover { background: var(--surface2); }
|
||
.result-item.active { background: var(--accent-glow); border-color: var(--accent); }
|
||
|
||
.result-poster {
|
||
width: 36px; height: 54px;
|
||
border-radius: 5px; object-fit: cover;
|
||
background: var(--surface2); flex-shrink: 0;
|
||
}
|
||
.result-info { flex: 1; min-width: 0; }
|
||
.result-name {
|
||
font-size: 13px; font-weight: 600;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.result-sub {
|
||
font-size: 11px; color: var(--text-2); margin-top: 2px;
|
||
display: flex; gap: 5px; flex-wrap: wrap;
|
||
}
|
||
.result-sub .tag {
|
||
background: var(--surface3);
|
||
padding: 1px 6px; border-radius: 3px;
|
||
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||
letter-spacing: 0.04em; color: var(--text-2);
|
||
}
|
||
.result-sub .badge {
|
||
color: var(--green); font-size: 10px;
|
||
}
|
||
|
||
.empty {
|
||
padding: 48px 20px; text-align: center;
|
||
color: var(--text-3); font-size: 13px;
|
||
}
|
||
.empty svg { opacity: 0.2; margin-bottom: 12px; display: block; margin-left: auto; margin-right: auto; }
|
||
|
||
/* ── Main ── */
|
||
.main {
|
||
display: flex; flex-direction: column; min-height: 0; overflow: auto;
|
||
}
|
||
|
||
.preview-area {
|
||
flex: 1;
|
||
display: flex; align-items: center; justify-content: center;
|
||
min-height: 280px;
|
||
padding: clamp(16px, 2.4vw, 28px);
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.preview-stack {
|
||
width: min(100%, 1180px);
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
gap: 18px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.preview-shell {
|
||
width: min(100%, 820px);
|
||
}
|
||
|
||
.primary-preview-shell {
|
||
width: min(100%, 290px);
|
||
display: none;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.primary-preview-shell.visible { display: flex; }
|
||
.primary-preview-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
color: var(--text-2);
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
}
|
||
.primary-preview-note {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
letter-spacing: 0;
|
||
text-transform: none;
|
||
font-size: 11px;
|
||
}
|
||
.primary-preview-tools {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.mini-link {
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--accent-h);
|
||
font: inherit;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
.mini-link:hover { color: #8fa4ff; }
|
||
.primary-preview-frame {
|
||
position: relative;
|
||
aspect-ratio: 2 / 3;
|
||
border-radius: var(--r-lg);
|
||
overflow: hidden;
|
||
background: var(--surface2);
|
||
box-shadow: 0 8px 40px rgba(0,0,0,0.45);
|
||
cursor: grab;
|
||
touch-action: none;
|
||
}
|
||
.primary-preview-frame.dragging { cursor: grabbing; }
|
||
.primary-preview-frame img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.primary-preview-zoom {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 12px;
|
||
display: flex;
|
||
gap: 8px;
|
||
z-index: 2;
|
||
}
|
||
.primary-preview-zoom-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 1px solid rgba(255,255,255,0.14);
|
||
border-radius: 999px;
|
||
background: rgba(0,0,0,0.58);
|
||
color: rgba(255,255,255,0.92);
|
||
font: inherit;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.primary-preview-zoom-btn:hover {
|
||
background: rgba(0,0,0,0.72);
|
||
}
|
||
.primary-preview-empty {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--text-3);
|
||
font-size: 12px;
|
||
text-align: center;
|
||
padding: 16px;
|
||
}
|
||
.primary-preview-hint {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 12px;
|
||
transform: translateX(-50%);
|
||
padding: 7px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(0,0,0,0.58);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
color: rgba(255,255,255,0.88);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
pointer-events: none;
|
||
backdrop-filter: blur(8px);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.upload-bg-group {
|
||
display: none;
|
||
gap: 6px;
|
||
align-items: center;
|
||
margin-top: 5px;
|
||
padding: 5px 8px;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
}
|
||
.upload-bg-group.visible { display: flex; }
|
||
.upload-bg-name {
|
||
font-size: 11px;
|
||
color: var(--green);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.preview-placeholder {
|
||
aspect-ratio: 16/9;
|
||
border-radius: var(--r-lg);
|
||
border: 1px dashed var(--border);
|
||
display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center;
|
||
gap: 12px; color: var(--text-3); font-size: 13px;
|
||
}
|
||
.preview-placeholder svg { opacity: 0.15; }
|
||
|
||
.preview-frame {
|
||
position: relative;
|
||
aspect-ratio: 16/9;
|
||
border-radius: var(--r-lg);
|
||
overflow: hidden;
|
||
background: var(--surface2);
|
||
box-shadow: 0 8px 40px rgba(0,0,0,0.5);
|
||
display: none;
|
||
cursor: grab;
|
||
touch-action: none;
|
||
}
|
||
.preview-frame.visible { display: block; }
|
||
.preview-frame.dragging { cursor: grabbing; }
|
||
.preview-frame img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
|
||
.thumb-preview-tools {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
z-index: 2;
|
||
padding: 6px 9px;
|
||
border-radius: 999px;
|
||
background: rgba(0,0,0,0.58);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.thumb-preview-meta {
|
||
color: rgba(255,255,255,0.88);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 11px;
|
||
white-space: nowrap;
|
||
}
|
||
.thumb-preview-zoom {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
display: flex;
|
||
gap: 8px;
|
||
z-index: 2;
|
||
}
|
||
.thumb-preview-hint {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 10px;
|
||
transform: translateX(-50%);
|
||
padding: 7px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(0,0,0,0.58);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
color: rgba(255,255,255,0.88);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
pointer-events: none;
|
||
backdrop-filter: blur(8px);
|
||
white-space: nowrap;
|
||
z-index: 2;
|
||
}
|
||
.bd-counter + .thumb-preview-hint {
|
||
bottom: 38px;
|
||
}
|
||
|
||
/* Backdrop navigation overlaid on the frame */
|
||
.bd-nav {
|
||
position: absolute; inset: 0;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 0 12px;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
}
|
||
.preview-frame:hover .bd-nav { opacity: 1; }
|
||
.bd-nav.always { opacity: 1; pointer-events: auto; }
|
||
|
||
.bd-btn {
|
||
width: 36px; height: 36px;
|
||
border-radius: 50%;
|
||
background: rgba(0,0,0,0.55);
|
||
border: 1px solid rgba(255,255,255,0.15);
|
||
color: #fff; font-size: 16px;
|
||
display: grid; place-items: center;
|
||
cursor: pointer;
|
||
pointer-events: auto;
|
||
backdrop-filter: blur(4px);
|
||
transition: background 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.bd-btn:hover { background: rgba(0,0,0,0.8); }
|
||
.bd-btn:disabled { opacity: 0.2; cursor: not-allowed; }
|
||
|
||
.bd-counter {
|
||
position: absolute; bottom: 10px; left: 50%;
|
||
transform: translateX(-50%);
|
||
background: rgba(0,0,0,0.55);
|
||
backdrop-filter: blur(4px);
|
||
border: 1px solid rgba(255,255,255,0.12);
|
||
border-radius: 20px;
|
||
padding: 3px 12px;
|
||
font-size: 11px; color: rgba(255,255,255,0.8);
|
||
pointer-events: none;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.loading-cover {
|
||
position: absolute; inset: 0;
|
||
background: rgba(13,15,18,0.8);
|
||
display: none; align-items: center; justify-content: center;
|
||
z-index: 4;
|
||
}
|
||
.loading-cover.on { display: flex; }
|
||
|
||
.spinner {
|
||
width: 32px; height: 32px;
|
||
border: 2.5px solid var(--border);
|
||
border-top-color: var(--accent);
|
||
border-radius: 50%;
|
||
animation: spin 0.7s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* ── Controls ── */
|
||
.controls {
|
||
flex-shrink: 0;
|
||
border-top: 1px solid var(--border);
|
||
background: var(--surface);
|
||
padding: 14px 20px;
|
||
display: none;
|
||
gap: 14px;
|
||
flex-direction: column;
|
||
}
|
||
.controls.visible { display: flex; }
|
||
|
||
.ctrl-row {
|
||
display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap;
|
||
}
|
||
|
||
.ctrl-group { display: flex; flex-direction: column; gap: 5px; }
|
||
.ctrl-group label {
|
||
font-size: 10px; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: 0.08em;
|
||
color: var(--text-2);
|
||
}
|
||
.ctrl-hint {
|
||
font-size: 10px;
|
||
color: var(--text-3);
|
||
max-width: 180px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.ctrl-select {
|
||
padding: 7px 10px;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text);
|
||
font-family: inherit; font-size: 12px;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
cursor: pointer;
|
||
}
|
||
.ctrl-select:focus { border-color: var(--border-active); }
|
||
.ctrl-select option { background: var(--surface); }
|
||
|
||
/* Logo position visual picker */
|
||
.pos-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 30px);
|
||
grid-template-rows: repeat(2, 24px);
|
||
gap: 3px;
|
||
}
|
||
.pos-btn {
|
||
border-radius: 5px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface2);
|
||
cursor: pointer;
|
||
display: grid; place-items: center;
|
||
transition: background 0.12s, border-color 0.12s;
|
||
padding: 0;
|
||
}
|
||
.pos-btn svg { width: 12px; height: 12px; opacity: 0.4; transition: opacity 0.12s; }
|
||
.pos-btn:hover { background: var(--surface3); }
|
||
.pos-btn.active {
|
||
background: var(--accent-glow);
|
||
border-color: var(--accent);
|
||
}
|
||
.pos-btn.active svg { opacity: 1; }
|
||
|
||
/* Color swatch */
|
||
.color-wrap { display: flex; gap: 6px; align-items: center; }
|
||
.color-wrap input[type="color"] {
|
||
width: 34px; height: 33px;
|
||
border: 1px solid var(--border); border-radius: 7px;
|
||
background: var(--surface2); cursor: pointer; padding: 2px;
|
||
}
|
||
.color-wrap input[type="text"] {
|
||
width: 80px;
|
||
padding: 7px 8px;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 7px;
|
||
color: var(--text);
|
||
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||
outline: none;
|
||
}
|
||
.color-wrap input[type="text"]:focus { border-color: var(--border-active); }
|
||
|
||
.slider-wrap {
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.slider-step {
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 7px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
display: grid;
|
||
place-items: center;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
transition: background 0.12s, border-color 0.12s;
|
||
}
|
||
.slider-step:hover { background: var(--surface3); border-color: var(--border-active); }
|
||
.slider-wrap input[type="range"] {
|
||
width: 100px;
|
||
accent-color: var(--accent);
|
||
cursor: pointer;
|
||
}
|
||
.slider-val {
|
||
font-size: 11px; color: var(--text-2);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
min-width: 28px; text-align: right;
|
||
}
|
||
.asset-picker {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-height: 33px;
|
||
}
|
||
.asset-btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 7px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 14px;
|
||
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||
}
|
||
.asset-btn:hover:not(:disabled) { background: var(--surface3); }
|
||
.asset-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
.asset-count {
|
||
min-width: 40px;
|
||
font-size: 11px;
|
||
color: var(--text-2);
|
||
font-family: 'JetBrains Mono', monospace;
|
||
text-align: center;
|
||
}
|
||
.logo-swatch {
|
||
width: 88px;
|
||
height: 30px;
|
||
border-radius: 7px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface2);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
padding: 4px 6px;
|
||
}
|
||
.logo-swatch img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
display: block;
|
||
}
|
||
.logo-swatch.empty {
|
||
color: var(--text-3);
|
||
font-size: 10px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.toggle-chip {
|
||
min-height: 33px;
|
||
padding: 0 10px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface2);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
}
|
||
.toggle-chip input {
|
||
appearance: none;
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg);
|
||
display: inline-block;
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
}
|
||
.toggle-chip input:checked {
|
||
background: #e50914;
|
||
border-color: #e50914;
|
||
}
|
||
.toggle-chip input:checked::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 2px;
|
||
background: #fff;
|
||
clip-path: polygon(14% 52%, 0 68%, 42% 100%, 100% 18%, 84% 4%, 40% 68%);
|
||
}
|
||
.toggle-chip span {
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
}
|
||
.tag-preview {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 7px;
|
||
border-radius: 4px;
|
||
background: #e50914;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.tag-preview-dark {
|
||
background: #111111;
|
||
}
|
||
|
||
.ctrl-spacer { flex: 1; }
|
||
|
||
.studio-grid {
|
||
display: flex; flex-wrap: wrap; gap: 4px;
|
||
max-width: 200px;
|
||
}
|
||
.studio-btn {
|
||
padding: 4px 8px;
|
||
border-radius: 5px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface2);
|
||
color: var(--text-2);
|
||
font-family: inherit; font-size: 11px; font-weight: 700;
|
||
cursor: pointer; white-space: nowrap;
|
||
transition: background 0.12s, border-color 0.12s;
|
||
letter-spacing: 0.03em;
|
||
}
|
||
.studio-btn:hover { background: var(--surface3); }
|
||
.studio-btn.active { background: var(--accent-glow); border-color: var(--accent); color: var(--text); }
|
||
|
||
.corner-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 3px; width: 62px;
|
||
}
|
||
.corner-btn {
|
||
height: 26px;
|
||
border-radius: 5px;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface2);
|
||
color: var(--text-2); font-size: 13px;
|
||
cursor: pointer;
|
||
transition: background 0.12s, border-color 0.12s;
|
||
display: grid; place-items: center;
|
||
}
|
||
.corner-btn:hover { background: var(--surface3); }
|
||
.corner-btn.active { background: var(--accent-glow); border-color: var(--accent); color: var(--text); }
|
||
|
||
/* Buttons */
|
||
.btn {
|
||
padding: 8px 18px;
|
||
border-radius: 8px; border: none;
|
||
font-family: inherit; font-size: 13px; font-weight: 600;
|
||
cursor: pointer; display: inline-flex; align-items: center; gap: 7px;
|
||
transition: all 0.13s; white-space: nowrap;
|
||
}
|
||
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||
.btn-ghost {
|
||
background: transparent; color: var(--text-2);
|
||
border: 1px solid var(--border);
|
||
}
|
||
.btn-ghost:hover:not(:disabled) { background: var(--surface2); color: var(--text); }
|
||
.btn-primary { background: var(--accent); color: #fff; }
|
||
.btn-primary:hover:not(:disabled) { background: var(--accent-h); }
|
||
.btn-green { background: var(--green); color: #0d0f12; }
|
||
.btn-green:hover:not(:disabled) { filter: brightness(1.08); }
|
||
|
||
/* Toast */
|
||
.toast {
|
||
position: fixed; bottom: 20px; right: 20px;
|
||
padding: 11px 18px; border-radius: var(--r);
|
||
font-size: 13px; font-weight: 500; z-index: 200;
|
||
transform: translateY(80px); opacity: 0;
|
||
transition: all 0.25s cubic-bezier(0.4,0,0.2,1);
|
||
pointer-events: none;
|
||
}
|
||
.toast.show { transform: translateY(0); opacity: 1; }
|
||
.toast.ok { background: var(--green-bg); border: 1px solid var(--green); color: var(--green); }
|
||
.toast.err { background: rgba(248,113,113,.1); border: 1px solid var(--red); color: var(--red); }
|
||
|
||
@media (max-width: 1400px) {
|
||
.body { grid-template-columns: 280px minmax(0, 1fr); }
|
||
.preview-shell { width: min(100%, 760px); }
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.body { grid-template-columns: 248px minmax(0, 1fr); }
|
||
.search-wrap,
|
||
.results-toolbar,
|
||
.results-footer,
|
||
.controls { padding-left: 12px; padding-right: 12px; }
|
||
.preview-area { padding: 16px; }
|
||
.preview-shell { width: min(100%, 680px); }
|
||
.primary-preview-shell { width: min(100%, 240px); }
|
||
.slider-wrap input[type="range"] { width: 80px; }
|
||
.studio-grid { max-width: 160px; }
|
||
.result-item { gap: 9px; padding: 8px; }
|
||
.result-name { font-size: 12px; }
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.body {
|
||
grid-template-columns: 1fr;
|
||
grid-template-rows: minmax(220px, 32vh) minmax(0, 1fr);
|
||
overflow: auto;
|
||
}
|
||
.sidebar {
|
||
border-right: none;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.main { overflow: visible; }
|
||
}
|
||
|
||
@media (max-height: 860px) {
|
||
.preview-area {
|
||
align-items: flex-start;
|
||
padding: 16px;
|
||
}
|
||
.preview-shell { width: min(100%, 680px); }
|
||
.primary-preview-shell { width: min(100%, 220px); }
|
||
.controls { padding: 12px 16px; }
|
||
}
|
||
|
||
@media (min-width: 981px) and (max-width: 1440px) and (max-height: 900px) {
|
||
.main {
|
||
justify-content: flex-start;
|
||
overflow: auto;
|
||
}
|
||
.preview-area {
|
||
flex: 0 1 auto;
|
||
align-items: flex-start;
|
||
padding: 12px 16px 8px;
|
||
}
|
||
.preview-shell { width: min(100%, 600px); }
|
||
.primary-preview-shell { width: min(100%, 220px); }
|
||
.controls {
|
||
padding: 10px 14px 12px;
|
||
gap: 10px;
|
||
}
|
||
.ctrl-row {
|
||
gap: 10px;
|
||
align-items: flex-start;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="header">
|
||
<div class="header-logo">TG</div>
|
||
<h1>Emby Thumbnail Generator</h1>
|
||
<nav class="header-nav">
|
||
<a class="nav-link active" href="/">Generator</a>
|
||
<a class="nav-link" href="/collections">Collections</a>
|
||
<a class="nav-link" href="/airing">Airing</a>
|
||
</nav>
|
||
<div class="header-status">
|
||
<span class="dot" id="statusDot"></span>
|
||
<span id="statusText">Checking…</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="body">
|
||
|
||
<!-- Sidebar -->
|
||
<aside class="sidebar">
|
||
<div class="search-wrap">
|
||
<div class="search-inner">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
<input class="search-input" id="searchInput" type="text" placeholder="Search movies & shows…" autocomplete="off">
|
||
</div>
|
||
</div>
|
||
<div class="results-toolbar" id="resultsToolbar" style="display:none">
|
||
<div class="results-count" id="resultsCount">0 results</div>
|
||
<div class="results-page" id="resultsPage">Page 1</div>
|
||
</div>
|
||
<div class="results" id="resultsList">
|
||
<div class="empty">
|
||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="2" y="2" width="20" height="20" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
|
||
Search your Emby library
|
||
</div>
|
||
</div>
|
||
<div class="results-footer" id="resultsFooter" style="display:none">
|
||
<button class="pager-btn" id="btnPrevPage" disabled>Previous</button>
|
||
<button class="pager-btn" id="btnNextPage" disabled>Next</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Main -->
|
||
<main class="main">
|
||
|
||
<div class="preview-area">
|
||
<div class="preview-stack">
|
||
<div class="preview-shell">
|
||
|
||
<!-- Placeholder -->
|
||
<div class="preview-placeholder" id="placeholder">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="m9 9 6 6m0-6-6 6"/></svg>
|
||
Select a title to get started
|
||
</div>
|
||
|
||
<!-- Preview frame -->
|
||
<div class="preview-frame" id="previewFrame" title="Drag to position the thumb crop. Use the mouse wheel to zoom.">
|
||
<img id="thumbImg" src="" alt="">
|
||
<div class="thumb-preview-tools">
|
||
<button class="mini-link" id="btnThumbReset" type="button">Reset</button>
|
||
<span class="thumb-preview-meta" id="thumbPreviewMeta">100% · X 0 · Y 0</span>
|
||
</div>
|
||
<div class="thumb-preview-zoom">
|
||
<button class="primary-preview-zoom-btn" id="btnThumbPreviewZoomOut" type="button" aria-label="Zoom out">-</button>
|
||
<button class="primary-preview-zoom-btn" id="btnThumbPreviewZoomIn" type="button" aria-label="Zoom in">+</button>
|
||
</div>
|
||
<div class="bd-nav" id="bdNav">
|
||
<button class="bd-btn" id="btnBdPrev" disabled>←</button>
|
||
<button class="bd-btn" id="btnBdNext" disabled>→</button>
|
||
</div>
|
||
<div class="bd-counter" id="bdCounter" style="display:none"></div>
|
||
<div class="thumb-preview-hint">Drag to position • Wheel to zoom</div>
|
||
<div class="loading-cover" id="loadingCover"><div class="spinner"></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="primary-preview-shell" id="primaryPreviewShell">
|
||
<div class="primary-preview-head">
|
||
<span>Primary Preview</span>
|
||
<div class="primary-preview-tools">
|
||
<button class="mini-link" id="btnPrimaryReset" type="button">Reset</button>
|
||
<span class="primary-preview-note" id="primaryPreviewMeta">100% · X 0 · Y -16</span>
|
||
</div>
|
||
</div>
|
||
<div class="primary-preview-frame" id="primaryPreviewFrame" title="Drag to position the poster crop. Use the mouse wheel to zoom.">
|
||
<img id="primaryPreviewImg" src="" alt="">
|
||
<div class="primary-preview-zoom">
|
||
<button class="primary-preview-zoom-btn" id="btnPrimaryPreviewZoomOut" type="button" aria-label="Zoom out">-</button>
|
||
<button class="primary-preview-zoom-btn" id="btnPrimaryPreviewZoomIn" type="button" aria-label="Zoom in">+</button>
|
||
</div>
|
||
<div class="primary-preview-empty" id="primaryPreviewEmpty">Enable matching primary to edit the poster crop.</div>
|
||
<div class="primary-preview-hint">Drag to position • Wheel to zoom</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Controls bar -->
|
||
<div class="controls" id="controlsBar">
|
||
<div class="ctrl-row">
|
||
|
||
<!-- Logo position -->
|
||
<div class="ctrl-group">
|
||
<label>Logo position</label>
|
||
<div class="pos-grid" id="posGrid">
|
||
<!-- Row 1: left / center / right (vertically centred) -->
|
||
<button class="pos-btn" data-pos="left" title="Left centre">
|
||
<svg viewBox="0 0 12 12" fill="currentColor"><rect x="1" y="4" width="5" height="4" rx="1"/></svg>
|
||
</button>
|
||
<button class="pos-btn" data-pos="center" title="Centre">
|
||
<svg viewBox="0 0 12 12" fill="currentColor"><rect x="3.5" y="4" width="5" height="4" rx="1"/></svg>
|
||
</button>
|
||
<button class="pos-btn" data-pos="right" title="Right centre" style="display:none">
|
||
<!-- hidden - not implemented yet -->
|
||
</button>
|
||
<!-- Row 2: bottom-left / bottom-center / bottom-right -->
|
||
<button class="pos-btn" data-pos="bottom-left" title="Bottom left">
|
||
<svg viewBox="0 0 12 12" fill="currentColor"><rect x="1" y="7" width="5" height="4" rx="1"/></svg>
|
||
</button>
|
||
<button class="pos-btn active" data-pos="bottom-center" title="Bottom centre">
|
||
<svg viewBox="0 0 12 12" fill="currentColor"><rect x="3.5" y="7" width="5" height="4" rx="1"/></svg>
|
||
</button>
|
||
<button class="pos-btn" data-pos="bottom-right" title="Bottom right">
|
||
<svg viewBox="0 0 12 12" fill="currentColor"><rect x="6" y="7" width="5" height="4" rx="1"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctrl-group" id="logoAssetGroup" style="display:none">
|
||
<label>Logo asset</label>
|
||
<div class="asset-picker">
|
||
<button class="asset-btn" id="btnLogoPrev" disabled>←</button>
|
||
<div class="logo-swatch empty" id="logoAssetPreview">No logo</div>
|
||
<button class="asset-btn" id="btnLogoNext" disabled>→</button>
|
||
<span class="asset-count" id="logoAssetCount">0/0</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctrl-group" id="backdropAssetGroup" style="display:none">
|
||
<label>Backdrop asset</label>
|
||
<div class="asset-picker">
|
||
<button class="asset-btn" id="btnBackdropPrev" disabled>←</button>
|
||
<span class="asset-count" id="backdropAssetCount">0/0</span>
|
||
<button class="asset-btn" id="btnBackdropNext" disabled>→</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Background -->
|
||
<div class="ctrl-group">
|
||
<label>Background</label>
|
||
<select class="ctrl-select" id="bgSelect">
|
||
<option value="backdrop" selected>Backdrop</option>
|
||
<option value="blur">Blurred poster</option>
|
||
<option value="upload">↑ Upload photo…</option>
|
||
</select>
|
||
<input type="file" id="uploadBgInput" accept="image/*" style="display:none">
|
||
<div class="upload-bg-group" id="uploadBgGroup">
|
||
<span class="upload-bg-name" id="uploadBgName"></span>
|
||
<button class="btn btn-ghost" id="btnUploadBg" type="button" style="font-size:10px;padding:3px 8px;margin-left:auto">Change</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Logo size -->
|
||
<div class="ctrl-group">
|
||
<label>Logo size</label>
|
||
<div class="slider-wrap">
|
||
<input type="range" id="logoScaleSlider" min="40" max="220" value="130">
|
||
<span class="slider-val" id="logoScaleVal">130%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Darkness -->
|
||
<div class="ctrl-group">
|
||
<label>Darkness</label>
|
||
<div class="slider-wrap">
|
||
<input type="range" id="darknessSlider" min="0" max="100" value="0">
|
||
<span class="slider-val" id="darknessVal">0%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Studio logo -->
|
||
<div class="ctrl-group">
|
||
<label>Studio</label>
|
||
<div class="studio-grid" id="studioGrid">
|
||
<button class="studio-btn active" data-studio="none">None</button>
|
||
<button class="studio-btn" data-studio="netflix" style="color:#e50914">NETFLIX</button>
|
||
<button class="studio-btn" data-studio="hulu" style="color:#1ce783">hulu</button>
|
||
<button class="studio-btn" data-studio="appletv">Apple TV+</button>
|
||
<button class="studio-btn" data-studio="disney" style="color:#6fa7e8">DISNEY+</button>
|
||
<button class="studio-btn" data-studio="hbo">HBO</button>
|
||
<button class="studio-btn" data-studio="prime" style="color:#00a8e1">prime</button>
|
||
<button class="studio-btn" data-studio="paramount" style="color:#6a90d4">PARAM+</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Studio position -->
|
||
<div class="ctrl-group" id="studioPosGroup" style="display:none">
|
||
<label>Studio pos</label>
|
||
<div class="corner-grid" id="studioCornerGrid">
|
||
<button class="corner-btn" data-pos="top-left" title="Top left">↖</button>
|
||
<button class="corner-btn" data-pos="top-right" title="Top right">↗</button>
|
||
<button class="corner-btn active" data-pos="bottom-right" title="Bottom right">↘</button>
|
||
<button class="corner-btn" data-pos="bottom-left" title="Bottom left">↙</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctrl-group" id="seriesTagGroup" style="display:none">
|
||
<label>Series tag</label>
|
||
<label class="toggle-chip" for="newEpisodesToggle">
|
||
<input type="checkbox" id="newEpisodesToggle">
|
||
<span>New episodes</span>
|
||
<span class="tag-preview">New Season</span>
|
||
</label>
|
||
<label class="toggle-chip" for="seasonFinaleToggle">
|
||
<input type="checkbox" id="seasonFinaleToggle">
|
||
<span>Season finale</span>
|
||
<span class="tag-preview tag-preview-dark">Season Finale</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="ctrl-group">
|
||
<label>Matching primary</label>
|
||
<label class="toggle-chip" for="matchPrimaryToggle">
|
||
<input type="checkbox" id="matchPrimaryToggle">
|
||
<span>Generate tall primary</span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Thumb crop -->
|
||
<div class="ctrl-group" id="thumbZoomGroup">
|
||
<label>Thumb zoom</label>
|
||
<div class="slider-wrap">
|
||
<button class="slider-step" id="btnThumbZoomOut" type="button" aria-label="Zoom out">-</button>
|
||
<input type="range" id="thumbZoomSlider" min="100" max="260" value="100">
|
||
<button class="slider-step" id="btnThumbZoomIn" type="button" aria-label="Zoom in">+</button>
|
||
<span class="slider-val" id="thumbZoomVal">100%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctrl-group" id="thumbPanXGroup">
|
||
<label>Thumb X</label>
|
||
<div class="slider-wrap">
|
||
<input type="range" id="thumbPanXSlider" min="-100" max="100" value="0">
|
||
<span class="slider-val" id="thumbPanXVal">0</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctrl-group" id="thumbPanYGroup">
|
||
<label>Thumb Y</label>
|
||
<div class="slider-wrap">
|
||
<input type="range" id="thumbPanYSlider" min="-100" max="100" value="0">
|
||
<span class="slider-val" id="thumbPanYVal">0</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctrl-group" id="primaryZoomGroup" style="display:none">
|
||
<label>Poster zoom</label>
|
||
<div class="slider-wrap">
|
||
<button class="slider-step" id="btnPrimaryZoomOut" type="button" aria-label="Zoom out">-</button>
|
||
<input type="range" id="primaryZoomSlider" min="100" max="260" value="100">
|
||
<button class="slider-step" id="btnPrimaryZoomIn" type="button" aria-label="Zoom in">+</button>
|
||
<span class="slider-val" id="primaryZoomVal">100%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctrl-group" id="primaryPanXGroup" style="display:none">
|
||
<label>Poster X</label>
|
||
<div class="slider-wrap">
|
||
<input type="range" id="primaryPanXSlider" min="-100" max="100" value="0">
|
||
<span class="slider-val" id="primaryPanXVal">0</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctrl-group" id="primaryPanYGroup" style="display:none">
|
||
<label>Poster Y</label>
|
||
<div class="slider-wrap">
|
||
<input type="range" id="primaryPanYSlider" min="-100" max="100" value="-16">
|
||
<span class="slider-val" id="primaryPanYVal">-16</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Text colour -->
|
||
<div class="ctrl-group">
|
||
<label>Text colour</label>
|
||
<div class="color-wrap">
|
||
<input type="color" id="textColorPicker" value="#FFFFFF">
|
||
<input type="text" id="textColorText" value="#FFFFFF" maxlength="7">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ctrl-group">
|
||
<label>Bulk category</label>
|
||
<select class="ctrl-select" id="bulkCategorySelect">
|
||
<option value="">Choose category…</option>
|
||
</select>
|
||
<div class="ctrl-hint">Uses the current settings. Only items that already have an Emby logo will be updated.</div>
|
||
</div>
|
||
|
||
<div class="ctrl-spacer"></div>
|
||
|
||
<!-- Actions -->
|
||
<button class="btn btn-ghost" id="btnRegenerate" disabled>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||
Regenerate
|
||
</button>
|
||
<button class="btn btn-primary" id="btnGenerate" disabled>Generate</button>
|
||
<button class="btn btn-green" id="btnApply" disabled>Apply to Emby</button>
|
||
<button class="btn btn-ghost" id="btnBulkApply" disabled>Bulk Apply Category</button>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const state = {
|
||
item: null,
|
||
backdropIndex: 0,
|
||
logoIndex: 0,
|
||
imageInfo: { logos: [], backdrops: [], primaries: [] },
|
||
generated: false,
|
||
cacheKey: null,
|
||
primaryGenerated: false,
|
||
uploadBgId: null,
|
||
searchQuery: '',
|
||
searchStart: 0,
|
||
searchLimit: 12,
|
||
searchTotal: 0,
|
||
searchController: null,
|
||
categoriesLoaded: false,
|
||
};
|
||
|
||
const $ = s => document.querySelector(s);
|
||
const searchInput = $('#searchInput');
|
||
const resultsToolbar = $('#resultsToolbar');
|
||
const resultsCount = $('#resultsCount');
|
||
const resultsPage = $('#resultsPage');
|
||
const resultsList = $('#resultsList');
|
||
const resultsFooter = $('#resultsFooter');
|
||
const btnPrevPage = $('#btnPrevPage');
|
||
const btnNextPage = $('#btnNextPage');
|
||
const placeholder = $('#placeholder');
|
||
const previewFrame = $('#previewFrame');
|
||
const thumbImg = $('#thumbImg');
|
||
const primaryPreviewShell = $('#primaryPreviewShell');
|
||
const primaryPreviewFrame = $('#primaryPreviewFrame');
|
||
const primaryPreviewImg = $('#primaryPreviewImg');
|
||
const primaryPreviewEmpty = $('#primaryPreviewEmpty');
|
||
const primaryPreviewMeta = $('#primaryPreviewMeta');
|
||
const btnPrimaryReset = $('#btnPrimaryReset');
|
||
const btnPrimaryPreviewZoomOut = $('#btnPrimaryPreviewZoomOut');
|
||
const btnPrimaryPreviewZoomIn = $('#btnPrimaryPreviewZoomIn');
|
||
const uploadBgGroup = $('#uploadBgGroup');
|
||
const uploadBgInput = $('#uploadBgInput');
|
||
const btnUploadBg = $('#btnUploadBg');
|
||
const uploadBgName = $('#uploadBgName');
|
||
const loadingCover = $('#loadingCover');
|
||
const bdNav = $('#bdNav');
|
||
const bdCounter = $('#bdCounter');
|
||
const logoAssetGroup = $('#logoAssetGroup');
|
||
const btnLogoPrev = $('#btnLogoPrev');
|
||
const btnLogoNext = $('#btnLogoNext');
|
||
const logoAssetPreview = $('#logoAssetPreview');
|
||
const logoAssetCount = $('#logoAssetCount');
|
||
const backdropAssetGroup = $('#backdropAssetGroup');
|
||
const btnBackdropPrev = $('#btnBackdropPrev');
|
||
const btnBackdropNext = $('#btnBackdropNext');
|
||
const backdropAssetCount = $('#backdropAssetCount');
|
||
const btnBdPrev = $('#btnBdPrev');
|
||
const btnBdNext = $('#btnBdNext');
|
||
const controlsBar = $('#controlsBar');
|
||
const posGrid = $('#posGrid');
|
||
const bgSelect = $('#bgSelect');
|
||
const textColorPicker=$('#textColorPicker');
|
||
const textColorText= $('#textColorText');
|
||
const logoScaleSlider = $('#logoScaleSlider');
|
||
const logoScaleVal = $('#logoScaleVal');
|
||
const darknessSlider = $('#darknessSlider');
|
||
const darknessVal = $('#darknessVal');
|
||
const studioGrid = $('#studioGrid');
|
||
const studioPosGroup = $('#studioPosGroup');
|
||
const studioCornerGrid = $('#studioCornerGrid');
|
||
const seriesTagGroup = $('#seriesTagGroup');
|
||
const newEpisodesToggle= $('#newEpisodesToggle');
|
||
const seasonFinaleToggle = $('#seasonFinaleToggle');
|
||
const matchPrimaryToggle = $('#matchPrimaryToggle');
|
||
const thumbZoomGroup = $('#thumbZoomGroup');
|
||
const thumbPanXGroup = $('#thumbPanXGroup');
|
||
const thumbPanYGroup = $('#thumbPanYGroup');
|
||
const btnThumbZoomOut = $('#btnThumbZoomOut');
|
||
const btnThumbZoomIn = $('#btnThumbZoomIn');
|
||
const btnThumbPreviewZoomOut = $('#btnThumbPreviewZoomOut');
|
||
const btnThumbPreviewZoomIn = $('#btnThumbPreviewZoomIn');
|
||
const btnThumbReset = $('#btnThumbReset');
|
||
const thumbPreviewMeta = $('#thumbPreviewMeta');
|
||
const thumbZoomSlider = $('#thumbZoomSlider');
|
||
const thumbZoomVal = $('#thumbZoomVal');
|
||
const thumbPanXSlider = $('#thumbPanXSlider');
|
||
const thumbPanXVal = $('#thumbPanXVal');
|
||
const thumbPanYSlider = $('#thumbPanYSlider');
|
||
const thumbPanYVal = $('#thumbPanYVal');
|
||
const primaryZoomGroup = $('#primaryZoomGroup');
|
||
const primaryPanXGroup = $('#primaryPanXGroup');
|
||
const primaryPanYGroup = $('#primaryPanYGroup');
|
||
const btnPrimaryZoomOut = $('#btnPrimaryZoomOut');
|
||
const btnPrimaryZoomIn = $('#btnPrimaryZoomIn');
|
||
const primaryZoomSlider = $('#primaryZoomSlider');
|
||
const primaryZoomVal = $('#primaryZoomVal');
|
||
const primaryPanXSlider = $('#primaryPanXSlider');
|
||
const primaryPanXVal = $('#primaryPanXVal');
|
||
const primaryPanYSlider = $('#primaryPanYSlider');
|
||
const primaryPanYVal = $('#primaryPanYVal');
|
||
const btnGenerate = $('#btnGenerate');
|
||
const btnRegenerate= $('#btnRegenerate');
|
||
const btnApply = $('#btnApply');
|
||
const bulkCategorySelect = $('#bulkCategorySelect');
|
||
const btnBulkApply = $('#btnBulkApply');
|
||
const toast = $('#toast');
|
||
|
||
// ── Connection check ──
|
||
(async () => {
|
||
try {
|
||
const d = await (await fetch('/api/config')).json();
|
||
if (d.connected) {
|
||
$('#statusText').textContent = 'Connected to Emby';
|
||
} else {
|
||
$('#statusDot').classList.add('off');
|
||
$('#statusText').textContent = 'No API key';
|
||
}
|
||
} catch {
|
||
$('#statusDot').classList.add('off');
|
||
$('#statusText').textContent = 'Connection error';
|
||
}
|
||
})();
|
||
|
||
async function readErrorMessage(response) {
|
||
try {
|
||
const data = await response.json();
|
||
if (typeof data?.detail === 'string' && data.detail.trim()) return data.detail;
|
||
if (Array.isArray(data?.detail)) return data.detail.map(entry => entry.msg || entry.message || String(entry)).join(', ');
|
||
} catch {}
|
||
return `${response.status}`;
|
||
}
|
||
|
||
async function loadCategories() {
|
||
if (state.categoriesLoaded) return;
|
||
try {
|
||
const response = await fetch('/api/categories');
|
||
if (!response.ok) throw new Error(await readErrorMessage(response));
|
||
const data = await response.json();
|
||
const options = (data.items || []).map(item =>
|
||
`<option value="${item.id}">${esc(item.name)}${item.item_count ? ` (${item.item_count})` : ''}</option>`
|
||
).join('');
|
||
bulkCategorySelect.innerHTML = `<option value="">Choose category…</option>${options}`;
|
||
state.categoriesLoaded = true;
|
||
} catch (error) {
|
||
bulkCategorySelect.innerHTML = '<option value="">Categories unavailable</option>';
|
||
showToast(`Category load failed: ${error.message}`, 'err');
|
||
}
|
||
}
|
||
|
||
function currentCategoryLabel() {
|
||
return bulkCategorySelect.options[bulkCategorySelect.selectedIndex]?.text || '';
|
||
}
|
||
|
||
function updateBulkApplyState() {
|
||
btnBulkApply.disabled = !state.item || !bulkCategorySelect.value;
|
||
}
|
||
|
||
// ── Search ──
|
||
let searchTimer;
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(searchTimer);
|
||
const q = searchInput.value.trim();
|
||
state.searchQuery = q;
|
||
state.searchStart = 0;
|
||
if (state.searchController) state.searchController.abort();
|
||
if (q.length < 2) {
|
||
resultsToolbar.style.display = 'none';
|
||
resultsFooter.style.display = 'none';
|
||
resultsList.innerHTML = '<div class="empty"><p>Type at least 2 characters</p></div>';
|
||
return;
|
||
}
|
||
searchTimer = setTimeout(() => doSearch(q, 0), 180);
|
||
});
|
||
|
||
function updateSearchPager(total, start, limit) {
|
||
if (!state.searchQuery || total <= 0) {
|
||
resultsToolbar.style.display = 'none';
|
||
resultsFooter.style.display = 'none';
|
||
return;
|
||
}
|
||
resultsToolbar.style.display = 'flex';
|
||
resultsFooter.style.display = total > limit ? 'flex' : 'none';
|
||
resultsCount.textContent = `${Math.min(start + 1, total)}-${Math.min(start + limit, total)} of ${total}`;
|
||
resultsPage.textContent = `Page ${Math.floor(start / limit) + 1}`;
|
||
btnPrevPage.disabled = start === 0;
|
||
btnNextPage.disabled = start + limit >= total;
|
||
}
|
||
|
||
function currentLogoAsset() {
|
||
if (state.logoIndex < 0) return null;
|
||
return state.imageInfo.logos[state.logoIndex] ?? null;
|
||
}
|
||
|
||
function currentBackdropAsset() {
|
||
return state.imageInfo.backdrops[state.backdropIndex] ?? null;
|
||
}
|
||
|
||
function updateAssetPickers() {
|
||
const logos = state.imageInfo.logos ?? [];
|
||
const backdrops = state.imageInfo.backdrops ?? [];
|
||
const totalLogoChoices = logos.length + 1;
|
||
const activeLogoChoice = state.logoIndex < 0 ? 1 : state.logoIndex + 2;
|
||
|
||
logoAssetGroup.style.display = state.item ? '' : 'none';
|
||
logoAssetCount.textContent = `${activeLogoChoice}/${totalLogoChoices}`;
|
||
btnLogoPrev.disabled = totalLogoChoices <= 1 || state.logoIndex < 0;
|
||
btnLogoNext.disabled = totalLogoChoices <= 1 || state.logoIndex >= logos.length - 1;
|
||
if (state.logoIndex < 0 || !logos.length) {
|
||
logoAssetPreview.classList.add('empty');
|
||
logoAssetPreview.textContent = 'Text';
|
||
} else {
|
||
logoAssetPreview.classList.remove('empty');
|
||
logoAssetPreview.innerHTML = `<img src="${logos[state.logoIndex].url}" alt="" loading="lazy" decoding="async">`;
|
||
}
|
||
|
||
backdropAssetGroup.style.display = backdrops.length > 1 ? '' : 'none';
|
||
backdropAssetCount.textContent = backdrops.length ? `${state.backdropIndex + 1}/${backdrops.length}` : '0/0';
|
||
btnBackdropPrev.disabled = backdrops.length <= 1 || state.backdropIndex === 0;
|
||
btnBackdropNext.disabled = backdrops.length <= 1 || state.backdropIndex >= backdrops.length - 1;
|
||
}
|
||
|
||
function syncSeriesTagToggles(changed) {
|
||
if (changed === 'new' && newEpisodesToggle.checked) seasonFinaleToggle.checked = false;
|
||
if (changed === 'finale' && seasonFinaleToggle.checked) newEpisodesToggle.checked = false;
|
||
}
|
||
|
||
function getSeriesTagFlags() {
|
||
const isSeries = state.item?.type === 'Series';
|
||
const seasonFinale = isSeries && seasonFinaleToggle.checked;
|
||
return {
|
||
newEpisodes: isSeries && newEpisodesToggle.checked && !seasonFinale,
|
||
seasonFinale,
|
||
};
|
||
}
|
||
|
||
function updateThumbPreviewMeta() {
|
||
thumbZoomVal.textContent = thumbZoomSlider.value + '%';
|
||
thumbPanXVal.textContent = thumbPanXSlider.value;
|
||
thumbPanYVal.textContent = thumbPanYSlider.value;
|
||
thumbPreviewMeta.textContent = `${thumbZoomSlider.value}% · X ${thumbPanXSlider.value} · Y ${thumbPanYSlider.value}`;
|
||
}
|
||
|
||
function updatePrimaryPreviewMeta() {
|
||
primaryZoomVal.textContent = primaryZoomSlider.value + '%';
|
||
primaryPanXVal.textContent = primaryPanXSlider.value;
|
||
primaryPanYVal.textContent = primaryPanYSlider.value;
|
||
primaryPreviewMeta.textContent = `${primaryZoomSlider.value}% · X ${primaryPanXSlider.value} · Y ${primaryPanYSlider.value}`;
|
||
}
|
||
|
||
function setPrimaryPreviewVisible(visible) {
|
||
primaryPreviewShell.classList.toggle('visible', visible);
|
||
primaryZoomGroup.style.display = visible ? '' : 'none';
|
||
primaryPanXGroup.style.display = visible ? '' : 'none';
|
||
primaryPanYGroup.style.display = visible ? '' : 'none';
|
||
}
|
||
|
||
function syncPrimaryPreviewState() {
|
||
const show = Boolean(state.item && matchPrimaryToggle.checked);
|
||
setPrimaryPreviewVisible(show);
|
||
if (!show) {
|
||
primaryPreviewImg.removeAttribute('src');
|
||
primaryPreviewEmpty.textContent = 'Enable matching primary to edit the poster crop.';
|
||
primaryPreviewEmpty.style.display = '';
|
||
return;
|
||
}
|
||
if (state.cacheKey && state.primaryGenerated) {
|
||
primaryPreviewImg.src = `/api/cache/${state.cacheKey}/primary?t=${Date.now()}`;
|
||
primaryPreviewEmpty.style.display = 'none';
|
||
} else {
|
||
primaryPreviewImg.removeAttribute('src');
|
||
primaryPreviewEmpty.textContent = 'Generate to preview the tall poster crop.';
|
||
primaryPreviewEmpty.style.display = '';
|
||
}
|
||
}
|
||
|
||
let primaryPreviewTimer;
|
||
let thumbPreviewTimer;
|
||
let generateController = null;
|
||
let generateRequestId = 0;
|
||
let primaryDragState = null;
|
||
let thumbDragState = null;
|
||
|
||
function clamp(n, min, max) {
|
||
return Math.max(min, Math.min(max, n));
|
||
}
|
||
|
||
function setThumbCropValues({ zoom, panX, panY }, { regenerate = false } = {}) {
|
||
if (zoom != null) thumbZoomSlider.value = String(clamp(Math.round(zoom), 100, 260));
|
||
if (panX != null) thumbPanXSlider.value = String(clamp(Math.round(panX), -100, 100));
|
||
if (panY != null) thumbPanYSlider.value = String(clamp(Math.round(panY), -100, 100));
|
||
updateThumbPreviewMeta();
|
||
if (regenerate) {
|
||
state.generated = false;
|
||
btnApply.disabled = true;
|
||
btnRegenerate.disabled = true;
|
||
scheduleThumbPreviewRegenerate();
|
||
}
|
||
}
|
||
|
||
function setPrimaryCropValues({ zoom, panX, panY }, { regenerate = false } = {}) {
|
||
if (zoom != null) primaryZoomSlider.value = String(clamp(Math.round(zoom), 100, 260));
|
||
if (panX != null) primaryPanXSlider.value = String(clamp(Math.round(panX), -100, 100));
|
||
if (panY != null) primaryPanYSlider.value = String(clamp(Math.round(panY), -100, 100));
|
||
updatePrimaryPreviewMeta();
|
||
if (regenerate) {
|
||
state.generated = false;
|
||
btnApply.disabled = true;
|
||
btnRegenerate.disabled = true;
|
||
schedulePrimaryPreviewRegenerate();
|
||
}
|
||
}
|
||
|
||
function scheduleThumbPreviewRegenerate() {
|
||
updateThumbPreviewMeta();
|
||
if (!state.item) return;
|
||
clearTimeout(thumbPreviewTimer);
|
||
thumbPreviewTimer = setTimeout(() => generateThumb({ silent: true }), 180);
|
||
}
|
||
|
||
function schedulePrimaryPreviewRegenerate() {
|
||
updatePrimaryPreviewMeta();
|
||
if (!state.item || !matchPrimaryToggle.checked) return;
|
||
clearTimeout(primaryPreviewTimer);
|
||
primaryPreviewTimer = setTimeout(() => generateThumb({ silent: true }), 180);
|
||
}
|
||
|
||
function finishPrimaryDrag(pointerId = null) {
|
||
if (!primaryDragState) return;
|
||
if (pointerId != null && primaryDragState.pointerId !== pointerId) return;
|
||
try {
|
||
primaryPreviewFrame.releasePointerCapture(primaryDragState.pointerId);
|
||
} catch {}
|
||
primaryPreviewFrame.classList.remove('dragging');
|
||
primaryDragState = null;
|
||
}
|
||
|
||
function finishThumbDrag(pointerId = null) {
|
||
if (!thumbDragState) return;
|
||
if (pointerId != null && thumbDragState.pointerId !== pointerId) return;
|
||
try {
|
||
previewFrame.releasePointerCapture(thumbDragState.pointerId);
|
||
} catch {}
|
||
previewFrame.classList.remove('dragging');
|
||
thumbDragState = null;
|
||
}
|
||
|
||
function nudgeThumbZoom(delta) {
|
||
if (!state.item) return;
|
||
setThumbCropValues({
|
||
zoom: Number(thumbZoomSlider.value) + delta,
|
||
}, { regenerate: true });
|
||
}
|
||
|
||
function nudgePrimaryZoom(delta) {
|
||
if (!state.item || !matchPrimaryToggle.checked) return;
|
||
setPrimaryCropValues({
|
||
zoom: Number(primaryZoomSlider.value) + delta,
|
||
}, { regenerate: true });
|
||
}
|
||
|
||
async function doSearch(q, start = 0) {
|
||
if (state.searchController) state.searchController.abort();
|
||
const controller = new AbortController();
|
||
state.searchController = controller;
|
||
state.searchQuery = q;
|
||
state.searchStart = start;
|
||
resultsList.innerHTML = '<div class="empty"><div class="spinner" style="margin:0 auto 0"></div></div>';
|
||
resultsToolbar.style.display = 'flex';
|
||
resultsFooter.style.display = 'none';
|
||
resultsCount.textContent = 'Searching...';
|
||
resultsPage.textContent = '';
|
||
try {
|
||
const r = await fetch(
|
||
`/api/search?q=${encodeURIComponent(q)}&start=${start}&limit=${state.searchLimit}`,
|
||
{ signal: controller.signal },
|
||
);
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(typeof data?.detail === 'string' ? data.detail : `${r.status}`);
|
||
if (state.searchController !== controller) return;
|
||
state.searchTotal = data.total;
|
||
state.searchStart = data.start;
|
||
if (!data.items.length) {
|
||
resultsList.innerHTML = '<div class="empty">No results</div>';
|
||
resultsCount.textContent = '0 results';
|
||
resultsPage.textContent = '';
|
||
resultsFooter.style.display = 'none';
|
||
return;
|
||
}
|
||
resultsList.innerHTML = data.items.map(it => `
|
||
<div class="result-item ${state.item?.id === it.id ? 'active' : ''}"
|
||
data-item='${JSON.stringify(it).replace(/'/g,"'")}'>
|
||
<img class="result-poster" src="${it.poster_url}" alt="" loading="lazy" decoding="async" fetchpriority="low" width="36" height="54">
|
||
<div class="result-info">
|
||
<div class="result-name">${esc(it.name)}</div>
|
||
<div class="result-sub">
|
||
<span>${it.year || '—'}</span>
|
||
<span class="tag">${it.type}</span>
|
||
${it.has_logo ? '<span class="badge">logo ✓</span>' : ''}
|
||
${it.backdrop_count > 0 ? `<span class="badge">${it.backdrop_count} backdrop${it.backdrop_count>1?'s':''}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`).join('');
|
||
updateSearchPager(data.total, data.start, data.limit);
|
||
resultsList.querySelectorAll('.result-item').forEach(el =>
|
||
el.addEventListener('click', () => selectItem(JSON.parse(el.dataset.item))));
|
||
} catch (e) {
|
||
if (e.name === 'AbortError') return;
|
||
resultsToolbar.style.display = 'none';
|
||
resultsFooter.style.display = 'none';
|
||
resultsList.innerHTML = `<div class="empty">Error: ${esc(e.message)}</div>`;
|
||
} finally {
|
||
if (state.searchController === controller) state.searchController = null;
|
||
}
|
||
}
|
||
|
||
btnPrevPage.addEventListener('click', () => {
|
||
if (!state.searchQuery || state.searchStart === 0) return;
|
||
doSearch(state.searchQuery, Math.max(0, state.searchStart - state.searchLimit));
|
||
});
|
||
|
||
btnNextPage.addEventListener('click', () => {
|
||
if (!state.searchQuery || state.searchStart + state.searchLimit >= state.searchTotal) return;
|
||
doSearch(state.searchQuery, state.searchStart + state.searchLimit);
|
||
});
|
||
|
||
// ── Select item ──
|
||
async function selectItem(item) {
|
||
// Don't re-select the same item
|
||
if (state.item?.id === item.id) return;
|
||
|
||
state.item = item;
|
||
state.backdropIndex = 0;
|
||
state.logoIndex = item.has_logo ? 0 : -1;
|
||
state.imageInfo = { logos: [], backdrops: [], primaries: [] };
|
||
state.generated = false;
|
||
state.cacheKey = null;
|
||
state.primaryGenerated = false;
|
||
primaryZoomSlider.value = '100';
|
||
primaryPanXSlider.value = '0';
|
||
primaryPanYSlider.value = '-16';
|
||
updateThumbPreviewMeta();
|
||
updatePrimaryPreviewMeta();
|
||
syncPrimaryPreviewState();
|
||
|
||
resultsList.querySelectorAll('.result-item').forEach(el => el.classList.remove('active'));
|
||
resultsList.querySelector(`[data-item*='"id":"${item.id}"']`)?.classList.add('active');
|
||
|
||
placeholder.style.display = 'none';
|
||
controlsBar.classList.add('visible');
|
||
btnRegenerate.disabled = true;
|
||
btnApply.disabled = true;
|
||
updateBulkApplyState();
|
||
bdCounter.style.display = 'none';
|
||
btnBdPrev.disabled = true;
|
||
btnBdNext.disabled = true;
|
||
updateAssetPickers();
|
||
seriesTagGroup.style.display = item.type === 'Series' ? '' : 'none';
|
||
if (item.type !== 'Series') {
|
||
newEpisodesToggle.checked = false;
|
||
seasonFinaleToggle.checked = false;
|
||
}
|
||
|
||
// Clear stale image and start generating immediately
|
||
thumbImg.src = '';
|
||
previewFrame.classList.add('visible');
|
||
generateThumb();
|
||
|
||
// Fetch accurate backdrop count in parallel
|
||
fetch(`/api/images/${item.id}`)
|
||
.then(r => r.json())
|
||
.then(info => {
|
||
state.imageInfo = {
|
||
logos: info.logos ?? [],
|
||
backdrops: info.backdrops ?? [],
|
||
primaries: info.primaries ?? [],
|
||
};
|
||
state.item.backdrop_count = info.backdrop_count;
|
||
if (!state.imageInfo.logos.length) {
|
||
state.logoIndex = -1;
|
||
} else if (state.logoIndex > state.imageInfo.logos.length - 1) {
|
||
state.logoIndex = 0;
|
||
}
|
||
state.backdropIndex = 0;
|
||
updateAssetPickers();
|
||
updateBdUI();
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function updateBdUI() {
|
||
const n = state.imageInfo.backdrops.length || state.item?.backdrop_count || 0;
|
||
if (n > 1) {
|
||
bdCounter.textContent = `Backdrop ${state.backdropIndex + 1} / ${n}`;
|
||
bdCounter.style.display = '';
|
||
btnBdPrev.disabled = state.backdropIndex === 0;
|
||
btnBdNext.disabled = state.backdropIndex >= n - 1;
|
||
} else {
|
||
bdCounter.style.display = 'none';
|
||
btnBdPrev.disabled = true;
|
||
btnBdNext.disabled = true;
|
||
}
|
||
}
|
||
|
||
function stepBackdrop(delta) {
|
||
const max = (state.imageInfo.backdrops.length || state.item?.backdrop_count || 0) - 1;
|
||
if (max < 0) return;
|
||
const nextIndex = Math.max(0, Math.min(max, state.backdropIndex + delta));
|
||
if (nextIndex === state.backdropIndex) return;
|
||
state.backdropIndex = nextIndex;
|
||
updateAssetPickers();
|
||
updateBdUI();
|
||
generateThumb();
|
||
}
|
||
|
||
function stepLogo(delta) {
|
||
const max = (state.imageInfo.logos.length || 0) - 1;
|
||
const nextIndex = Math.max(-1, Math.min(max, state.logoIndex + delta));
|
||
if (nextIndex === state.logoIndex) return;
|
||
state.logoIndex = nextIndex;
|
||
updateAssetPickers();
|
||
generateThumb();
|
||
}
|
||
|
||
btnBdPrev.addEventListener('click', () => stepBackdrop(-1));
|
||
btnBdNext.addEventListener('click', () => stepBackdrop(1));
|
||
btnBackdropPrev.addEventListener('click', () => stepBackdrop(-1));
|
||
btnBackdropNext.addEventListener('click', () => stepBackdrop(1));
|
||
btnLogoPrev.addEventListener('click', () => stepLogo(-1));
|
||
btnLogoNext.addEventListener('click', () => stepLogo(1));
|
||
|
||
// ── Logo position picker ──
|
||
let logoAlign = 'bottom-center';
|
||
posGrid.querySelectorAll('.pos-btn[data-pos]').forEach(btn => {
|
||
if (btn.style.display === 'none') return;
|
||
btn.addEventListener('click', () => {
|
||
posGrid.querySelectorAll('.pos-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
logoAlign = btn.dataset.pos;
|
||
});
|
||
});
|
||
|
||
logoScaleSlider.addEventListener('input', () => logoScaleVal.textContent = logoScaleSlider.value + '%');
|
||
darknessSlider.addEventListener('input', () => darknessVal.textContent = darknessSlider.value + '%');
|
||
|
||
// Studio selector
|
||
let selectedStudio = 'none';
|
||
let studioPos = 'bottom-right';
|
||
|
||
studioGrid.querySelectorAll('.studio-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
studioGrid.querySelectorAll('.studio-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
selectedStudio = btn.dataset.studio;
|
||
studioPosGroup.style.display = selectedStudio === 'none' ? 'none' : '';
|
||
});
|
||
});
|
||
|
||
studioCornerGrid.querySelectorAll('.corner-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
studioCornerGrid.querySelectorAll('.corner-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
studioPos = btn.dataset.pos;
|
||
});
|
||
});
|
||
textColorPicker.addEventListener('input', () => textColorText.value = textColorPicker.value);
|
||
textColorText.addEventListener('input', () => { if (/^#[0-9a-f]{6}$/i.test(textColorText.value)) textColorPicker.value = textColorText.value; });
|
||
newEpisodesToggle.addEventListener('change', () => syncSeriesTagToggles('new'));
|
||
seasonFinaleToggle.addEventListener('change', () => syncSeriesTagToggles('finale'));
|
||
matchPrimaryToggle.addEventListener('change', () => {
|
||
syncPrimaryPreviewState();
|
||
if (state.item) generateThumb();
|
||
});
|
||
primaryZoomSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
||
primaryPanXSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
||
primaryPanYSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
||
|
||
// Thumb crop sliders
|
||
thumbZoomSlider.addEventListener('input', () => {
|
||
setThumbCropValues({ zoom: Number(thumbZoomSlider.value) }, { regenerate: true });
|
||
});
|
||
thumbPanXSlider.addEventListener('input', () => {
|
||
setThumbCropValues({ panX: Number(thumbPanXSlider.value) }, { regenerate: true });
|
||
});
|
||
thumbPanYSlider.addEventListener('input', () => {
|
||
setThumbCropValues({ panY: Number(thumbPanYSlider.value) }, { regenerate: true });
|
||
});
|
||
btnThumbZoomOut.addEventListener('click', () => nudgeThumbZoom(-10));
|
||
btnThumbZoomIn.addEventListener('click', () => nudgeThumbZoom(10));
|
||
btnThumbPreviewZoomOut.addEventListener('click', () => nudgeThumbZoom(-10));
|
||
btnThumbPreviewZoomIn.addEventListener('click', () => nudgeThumbZoom(10));
|
||
btnThumbReset.addEventListener('click', () => {
|
||
setThumbCropValues({ zoom: 100, panX: 0, panY: 0 }, { regenerate: true });
|
||
});
|
||
previewFrame.addEventListener('pointerdown', event => {
|
||
if (!state.item || event.target.closest('button')) return;
|
||
event.preventDefault();
|
||
thumbDragState = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
startPanX: Number(thumbPanXSlider.value),
|
||
startPanY: Number(thumbPanYSlider.value),
|
||
};
|
||
previewFrame.classList.add('dragging');
|
||
previewFrame.setPointerCapture(event.pointerId);
|
||
});
|
||
previewFrame.addEventListener('pointermove', event => {
|
||
if (!thumbDragState || thumbDragState.pointerId !== event.pointerId) return;
|
||
const rect = previewFrame.getBoundingClientRect();
|
||
const deltaX = event.clientX - thumbDragState.startX;
|
||
const deltaY = event.clientY - thumbDragState.startY;
|
||
setThumbCropValues({
|
||
panX: thumbDragState.startPanX - (deltaX / Math.max(1, rect.width)) * 200,
|
||
panY: thumbDragState.startPanY - (deltaY / Math.max(1, rect.height)) * 200,
|
||
}, { regenerate: true });
|
||
});
|
||
previewFrame.addEventListener('pointerup', event => finishThumbDrag(event.pointerId));
|
||
previewFrame.addEventListener('pointercancel', event => finishThumbDrag(event.pointerId));
|
||
previewFrame.addEventListener('lostpointercapture', () => finishThumbDrag());
|
||
previewFrame.addEventListener('wheel', event => {
|
||
if (!state.item) return;
|
||
event.preventDefault();
|
||
const delta = event.deltaY < 0 ? 8 : -8;
|
||
nudgeThumbZoom(delta);
|
||
}, { passive: false });
|
||
|
||
// ── Background select / upload ──
|
||
bgSelect.addEventListener('change', () => {
|
||
if (bgSelect.value === 'upload') {
|
||
// Immediately open file picker when this option is chosen
|
||
uploadBgInput.click();
|
||
} else {
|
||
uploadBgGroup.classList.remove('visible');
|
||
state.uploadBgId = null;
|
||
}
|
||
});
|
||
btnUploadBg.addEventListener('click', () => uploadBgInput.click());
|
||
uploadBgInput.addEventListener('change', async () => {
|
||
const file = uploadBgInput.files[0];
|
||
if (!file) {
|
||
// User cancelled — revert to backdrop if no file was previously uploaded
|
||
if (!state.uploadBgId) bgSelect.value = 'backdrop';
|
||
return;
|
||
}
|
||
uploadBgName.textContent = file.name;
|
||
uploadBgGroup.classList.add('visible');
|
||
btnUploadBg.disabled = true;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
const r = await fetch('/api/upload-background', { method: 'POST', body: fd });
|
||
if (!r.ok) throw new Error(`${r.status}`);
|
||
const data = await r.json();
|
||
state.uploadBgId = data.upload_id;
|
||
showToast(`Photo ready (${data.width}×${data.height})`, 'ok');
|
||
if (state.item) generateThumb();
|
||
} catch (e) {
|
||
showToast(`Upload failed: ${e.message}`, 'err');
|
||
state.uploadBgId = null;
|
||
bgSelect.value = 'backdrop';
|
||
uploadBgGroup.classList.remove('visible');
|
||
} finally {
|
||
btnUploadBg.disabled = false;
|
||
}
|
||
});
|
||
|
||
btnPrimaryZoomOut.addEventListener('click', () => {
|
||
nudgePrimaryZoom(-10);
|
||
});
|
||
btnPrimaryZoomIn.addEventListener('click', () => {
|
||
nudgePrimaryZoom(10);
|
||
});
|
||
btnPrimaryPreviewZoomOut.addEventListener('click', () => nudgePrimaryZoom(-10));
|
||
btnPrimaryPreviewZoomIn.addEventListener('click', () => nudgePrimaryZoom(10));
|
||
btnPrimaryReset.addEventListener('click', () => {
|
||
setPrimaryCropValues({ zoom: 100, panX: 0, panY: -16 }, { regenerate: true });
|
||
});
|
||
primaryPreviewFrame.addEventListener('pointerdown', event => {
|
||
if (!state.item || !matchPrimaryToggle.checked || !state.primaryGenerated) return;
|
||
event.preventDefault();
|
||
primaryDragState = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
startPanX: Number(primaryPanXSlider.value),
|
||
startPanY: Number(primaryPanYSlider.value),
|
||
};
|
||
primaryPreviewFrame.classList.add('dragging');
|
||
primaryPreviewFrame.setPointerCapture(event.pointerId);
|
||
});
|
||
primaryPreviewFrame.addEventListener('pointermove', event => {
|
||
if (!primaryDragState || primaryDragState.pointerId !== event.pointerId) return;
|
||
const rect = primaryPreviewFrame.getBoundingClientRect();
|
||
const deltaX = event.clientX - primaryDragState.startX;
|
||
const deltaY = event.clientY - primaryDragState.startY;
|
||
setPrimaryCropValues({
|
||
panX: primaryDragState.startPanX - (deltaX / Math.max(1, rect.width)) * 200,
|
||
panY: primaryDragState.startPanY - (deltaY / Math.max(1, rect.height)) * 200,
|
||
}, { regenerate: true });
|
||
});
|
||
primaryPreviewFrame.addEventListener('pointerup', event => finishPrimaryDrag(event.pointerId));
|
||
primaryPreviewFrame.addEventListener('pointercancel', event => finishPrimaryDrag(event.pointerId));
|
||
primaryPreviewFrame.addEventListener('lostpointercapture', () => finishPrimaryDrag());
|
||
primaryPreviewFrame.addEventListener('wheel', event => {
|
||
if (!state.item || !matchPrimaryToggle.checked) return;
|
||
event.preventDefault();
|
||
const delta = event.deltaY < 0 ? 8 : -8;
|
||
nudgePrimaryZoom(delta);
|
||
}, { passive: false });
|
||
updateThumbPreviewMeta();
|
||
updatePrimaryPreviewMeta();
|
||
|
||
// ── Generate ──
|
||
async function generateThumb({ silent = false } = {}) {
|
||
if (!state.item) return;
|
||
clearTimeout(primaryPreviewTimer);
|
||
clearTimeout(thumbPreviewTimer);
|
||
if (generateController) generateController.abort();
|
||
const controller = new AbortController();
|
||
generateController = controller;
|
||
const requestId = ++generateRequestId;
|
||
previewFrame.classList.add('visible');
|
||
loadingCover.classList.add('on');
|
||
btnGenerate.disabled = true;
|
||
btnRegenerate.disabled = true;
|
||
|
||
try {
|
||
const seriesTagFlags = getSeriesTagFlags();
|
||
const r = await fetch('/api/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
signal: controller.signal,
|
||
body: JSON.stringify({
|
||
item_id: state.item.id,
|
||
title: state.item.name,
|
||
bg_mode: bgSelect.value,
|
||
upload_bg_id: bgSelect.value === 'upload' ? state.uploadBgId : null,
|
||
text_color: textColorText.value,
|
||
logo_align: logoAlign,
|
||
logo_index: currentLogoAsset()?.index ?? null,
|
||
logo_scale: logoScaleSlider.value / 100,
|
||
darkness: darknessSlider.value / 100,
|
||
studio: selectedStudio,
|
||
studio_position: studioPos,
|
||
new_episodes_tag: seriesTagFlags.newEpisodes,
|
||
season_finale_tag: seriesTagFlags.seasonFinale,
|
||
generate_primary: matchPrimaryToggle.checked,
|
||
primary_zoom: primaryZoomSlider.value / 100,
|
||
primary_pan_x: primaryPanXSlider.value / 100,
|
||
primary_pan_y: primaryPanYSlider.value / 100,
|
||
thumb_zoom: thumbZoomSlider.value / 100,
|
||
thumb_pan_x: thumbPanXSlider.value / 100,
|
||
thumb_pan_y: thumbPanYSlider.value / 100,
|
||
backdrop_index: currentBackdropAsset()?.index ?? state.backdropIndex,
|
||
}),
|
||
});
|
||
if (!r.ok) throw new Error(await readErrorMessage(r));
|
||
if (requestId !== generateRequestId) return;
|
||
const primaryGenerated = r.headers.get('X-Primary-Generated') === '1';
|
||
state.cacheKey = r.headers.get('X-Cache-Key');
|
||
state.primaryGenerated = primaryGenerated;
|
||
const blob = await r.blob();
|
||
if (requestId !== generateRequestId) return;
|
||
// Revoke old URL to avoid memory leak
|
||
if (thumbImg.src.startsWith('blob:')) URL.revokeObjectURL(thumbImg.src);
|
||
thumbImg.src = URL.createObjectURL(blob);
|
||
syncPrimaryPreviewState();
|
||
state.generated = true;
|
||
btnApply.disabled = false;
|
||
btnRegenerate.disabled = false;
|
||
updateBdUI();
|
||
if (!silent) {
|
||
if (matchPrimaryToggle.checked && primaryGenerated) {
|
||
showToast('Generated thumb + primary', 'ok');
|
||
} else if (matchPrimaryToggle.checked) {
|
||
showToast('Generated thumb', 'ok');
|
||
} else {
|
||
showToast('Generated', 'ok');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (e.name === 'AbortError') return;
|
||
if (requestId !== generateRequestId) return;
|
||
state.cacheKey = null;
|
||
state.primaryGenerated = false;
|
||
syncPrimaryPreviewState();
|
||
showToast(`Failed: ${e.message}`, 'err');
|
||
btnRegenerate.disabled = !state.generated;
|
||
} finally {
|
||
if (generateController === controller) generateController = null;
|
||
if (requestId === generateRequestId) {
|
||
loadingCover.classList.remove('on');
|
||
btnGenerate.disabled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
btnGenerate.addEventListener('click', generateThumb);
|
||
btnRegenerate.addEventListener('click', generateThumb);
|
||
bulkCategorySelect.addEventListener('change', updateBulkApplyState);
|
||
|
||
// ── Apply ──
|
||
btnApply.addEventListener('click', async () => {
|
||
if (!state.generated) return;
|
||
btnApply.disabled = true;
|
||
try {
|
||
const seriesTagFlags = getSeriesTagFlags();
|
||
const r = await fetch('/api/apply', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
item_id: state.item.id,
|
||
title: state.item.name,
|
||
bg_mode: bgSelect.value,
|
||
upload_bg_id: bgSelect.value === 'upload' ? state.uploadBgId : null,
|
||
text_color: textColorText.value,
|
||
logo_align: logoAlign,
|
||
logo_index: currentLogoAsset()?.index ?? null,
|
||
logo_scale: logoScaleSlider.value / 100,
|
||
darkness: darknessSlider.value / 100,
|
||
studio: selectedStudio,
|
||
studio_position: studioPos,
|
||
new_episodes_tag: seriesTagFlags.newEpisodes,
|
||
season_finale_tag: seriesTagFlags.seasonFinale,
|
||
generate_primary: matchPrimaryToggle.checked,
|
||
primary_zoom: primaryZoomSlider.value / 100,
|
||
primary_pan_x: primaryPanXSlider.value / 100,
|
||
primary_pan_y: primaryPanYSlider.value / 100,
|
||
thumb_zoom: thumbZoomSlider.value / 100,
|
||
thumb_pan_x: thumbPanXSlider.value / 100,
|
||
thumb_pan_y: thumbPanYSlider.value / 100,
|
||
backdrop_index: currentBackdropAsset()?.index ?? state.backdropIndex,
|
||
}),
|
||
});
|
||
if (!r.ok) throw new Error(await readErrorMessage(r));
|
||
const data = await r.json();
|
||
if (data.primary_attempted && data.primary_code) {
|
||
showToast('Applied thumb + primary poster', 'ok');
|
||
} else if (data.primary_attempted && data.primary_error) {
|
||
showToast('Applied thumb only', 'ok');
|
||
} else {
|
||
showToast('Applied to Emby!', 'ok');
|
||
}
|
||
} catch (e) {
|
||
showToast(`Failed: ${e.message}`, 'err');
|
||
} finally {
|
||
btnApply.disabled = false;
|
||
}
|
||
});
|
||
|
||
btnBulkApply.addEventListener('click', async () => {
|
||
if (!state.item || !bulkCategorySelect.value) return;
|
||
const categoryLabel = currentCategoryLabel();
|
||
const confirmed = window.confirm(`Bulk apply the current settings to "${categoryLabel}"?\n\nOnly items with Emby logos will be updated.`);
|
||
if (!confirmed) return;
|
||
btnBulkApply.disabled = true;
|
||
try {
|
||
const seriesTagFlags = getSeriesTagFlags();
|
||
const response = await fetch('/api/bulk-apply/category', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
category_id: bulkCategorySelect.value,
|
||
category_name: categoryLabel,
|
||
item_id: state.item.id,
|
||
title: state.item.name,
|
||
bg_mode: bgSelect.value,
|
||
upload_bg_id: bgSelect.value === 'upload' ? state.uploadBgId : null,
|
||
text_color: textColorText.value,
|
||
logo_align: logoAlign,
|
||
logo_index: currentLogoAsset()?.index ?? null,
|
||
logo_scale: logoScaleSlider.value / 100,
|
||
darkness: darknessSlider.value / 100,
|
||
studio: selectedStudio,
|
||
studio_position: studioPos,
|
||
new_episodes_tag: seriesTagFlags.newEpisodes,
|
||
season_finale_tag: seriesTagFlags.seasonFinale,
|
||
generate_primary: matchPrimaryToggle.checked,
|
||
primary_zoom: primaryZoomSlider.value / 100,
|
||
primary_pan_x: primaryPanXSlider.value / 100,
|
||
primary_pan_y: primaryPanYSlider.value / 100,
|
||
thumb_zoom: thumbZoomSlider.value / 100,
|
||
thumb_pan_x: thumbPanXSlider.value / 100,
|
||
thumb_pan_y: thumbPanYSlider.value / 100,
|
||
backdrop_index: currentBackdropAsset()?.index ?? state.backdropIndex,
|
||
}),
|
||
});
|
||
if (!response.ok) throw new Error(await readErrorMessage(response));
|
||
const data = await response.json();
|
||
showToast(
|
||
`Bulk complete: ${data.applied_count}/${data.eligible_count} applied, ${data.skipped_without_logo_count} skipped without logo, ${data.failed_count} failed`,
|
||
data.failed_count ? 'err' : 'ok',
|
||
);
|
||
} catch (error) {
|
||
showToast(`Bulk apply failed: ${error.message}`, 'err');
|
||
} finally {
|
||
updateBulkApplyState();
|
||
}
|
||
});
|
||
|
||
// ── Utils ──
|
||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||
function showToast(msg, type) {
|
||
toast.textContent = msg;
|
||
toast.className = `toast ${type} show`;
|
||
setTimeout(() => toast.classList.remove('show'), 2800);
|
||
}
|
||
|
||
loadCategories();
|
||
</script>
|
||
</body>
|
||
</html>
|