Files
embycovers/templates/index.html
T
2026-04-20 23:16:53 +12:00

2103 lines
72 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>&#8592;</button>
<button class="bd-btn" id="btnBdNext" disabled>&#8594;</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>&#8592;</button>
<div class="logo-swatch empty" id="logoAssetPreview">No logo</div>
<button class="asset-btn" id="btnLogoNext" disabled>&#8594;</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>&#8592;</button>
<span class="asset-count" id="backdropAssetCount">0/0</span>
<button class="asset-btn" id="btnBackdropNext" disabled>&#8594;</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,"&#39;")}'>
<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>