Files
embycovers/templates/index.html
T

2367 lines
81 KiB
HTML
Raw Normal View History

2026-04-15 09:27:29 +12:00
<!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;
}
2026-04-15 21:25:03 +12:00
.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);
}
2026-04-15 09:27:29 +12:00
.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;
}
2026-04-15 21:25:03 +12:00
.artwork-editor {
margin: 0 clamp(16px, 2.4vw, 28px) clamp(14px, 2vw, 22px);
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: linear-gradient(135deg, rgba(26,32,48,0.92), rgba(13,15,18,0.94));
box-shadow: 0 18px 60px rgba(0,0,0,0.35);
display: none;
overflow: hidden;
}
.artwork-editor.visible { display: block; }
.editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.editor-title {
font-size: 13px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.editor-subtitle {
margin-top: 3px;
color: var(--text-2);
font-size: 12px;
}
.editor-grid {
display: grid;
grid-template-columns: minmax(190px, 0.8fr) minmax(260px, 1.2fr) minmax(220px, 0.9fr);
gap: 14px;
padding: 14px;
}
.editor-panel {
border: 1px solid var(--border);
border-radius: var(--r);
background: rgba(13,15,18,0.54);
padding: 12px;
}
.editor-panel h3 {
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-2);
margin-bottom: 10px;
}
.editor-source-list {
display: grid;
gap: 8px;
}
.editor-source-btn {
border: 1px solid var(--border);
border-radius: 9px;
padding: 9px 10px;
background: var(--surface2);
color: var(--text);
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.editor-source-btn.active {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.editor-canvas-wrap {
display: grid;
place-items: center;
min-height: 360px;
}
.editor-canvas {
position: relative;
width: min(100%, 330px);
aspect-ratio: 2 / 3;
border-radius: 16px;
overflow: hidden;
background: #07080a;
border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 20px 70px rgba(0,0,0,0.55);
cursor: grab;
touch-action: none;
}
.editor-canvas.thumb,
.editor-canvas.backdrop { aspect-ratio: 16 / 9; width: min(100%, 470px); }
.editor-canvas.dragging { cursor: grabbing; }
.editor-canvas img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.editor-canvas-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--text-3);
font-size: 12px;
text-align: center;
padding: 20px;
}
.editor-row {
display: grid;
gap: 6px;
margin-bottom: 10px;
}
.editor-row label {
color: var(--text-2);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.editor-row select,
.editor-row input[type="text"],
.editor-row input[type="color"] {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface2);
color: var(--text);
padding: 8px;
font: inherit;
font-size: 12px;
}
.editor-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.editor-note {
color: var(--text-2);
font-size: 12px;
line-height: 1.45;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 500;
display: none;
place-items: center;
background: rgba(0,0,0,0.72);
padding: 22px;
}
.modal-backdrop.visible { display: grid; }
.modal-card {
width: min(920px, 100%);
max-height: min(760px, 92vh);
overflow: auto;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
box-shadow: 0 24px 90px rgba(0,0,0,0.6);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--border);
}
.modal-body { padding: 16px; }
.image-search-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
margin-top: 14px;
}
.image-result {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface2);
overflow: hidden;
}
.image-result img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
display: block;
}
.image-result div {
padding: 9px;
font-size: 11px;
color: var(--text-2);
}
2026-04-15 09:27:29 +12:00
.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;
}
.preview-frame.visible { display: block; }
.preview-frame img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* 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;
}
.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-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="/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">
<img id="thumbImg" src="" alt="">
<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="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="">
2026-04-15 21:25:03 +12:00
<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>
2026-04-15 09:27:29 +12:00
<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>
2026-04-15 21:25:03 +12:00
<section class="artwork-editor" id="artworkEditor">
<div class="editor-head">
<div>
<div class="editor-title">Artwork Editor</div>
<div class="editor-subtitle" id="editorSubtitle">Select an Emby item to reframe poster, thumb, or backdrop artwork.</div>
</div>
<div class="editor-actions">
<button class="btn btn-ghost" id="btnOpenImageSearch" type="button">Search web image</button>
<button class="btn btn-primary" id="btnEditorPreview" type="button">Preview export</button>
</div>
</div>
<div class="editor-grid">
<div class="editor-panel">
<h3>Source Image</h3>
<div class="editor-source-list" id="editorSourceList">
<button class="editor-source-btn active" type="button" data-source-kind="emby" data-source-type="Primary">Emby Primary poster</button>
<button class="editor-source-btn" type="button" data-source-kind="emby" data-source-type="Backdrop">Selected Emby backdrop</button>
<button class="editor-source-btn" type="button" data-source-kind="emby" data-source-type="Thumb">Current Emby thumb</button>
</div>
<p class="editor-note" id="editorImportNote" style="margin-top:12px">Remote imports will appear here after you search and import a full-size image.</p>
</div>
<div class="editor-panel">
<h3>Editor Canvas</h3>
<div class="editor-canvas-wrap">
<div class="editor-canvas" id="editorCanvas" title="Drag to reposition. Use the wheel or zoom controls to scale.">
<img id="editorPreviewImg" alt="">
<div class="editor-canvas-empty" id="editorCanvasEmpty">Generate a preview to start editing.</div>
</div>
</div>
<div class="editor-row">
<label>Zoom <span id="editorZoomVal">100%</span></label>
<input type="range" id="editorZoomSlider" min="25" max="500" value="100">
</div>
<div class="editor-row">
<label>Position X <span id="editorPanXVal">0</span></label>
<input type="range" id="editorPanXSlider" min="-100" max="100" value="0">
</div>
<div class="editor-row">
<label>Position Y <span id="editorPanYVal">0</span></label>
<input type="range" id="editorPanYSlider" min="-100" max="100" value="0">
</div>
<div class="editor-actions">
<button class="btn btn-ghost" id="btnEditorZoomOut" type="button">Zoom out</button>
<button class="btn btn-ghost" id="btnEditorZoomIn" type="button">Zoom in</button>
<button class="btn btn-ghost" id="btnEditorReset" type="button">Reset</button>
</div>
</div>
<div class="editor-panel">
<h3>Export</h3>
<div class="editor-row">
<label>Target mode</label>
<select id="editorTargetSelect">
<option value="poster">Poster / Primary 1000x1500</option>
<option value="thumb">Thumb 1920x1080</option>
<option value="backdrop">Backdrop 1920x1080</option>
</select>
</div>
<div class="editor-row">
<label>Image fit</label>
<select id="editorFitSelect">
<option value="cover">Cover frame</option>
<option value="contain">Contain full image</option>
</select>
</div>
<div class="editor-row">
<label>Background fill</label>
<select id="editorFillSelect">
<option value="blur">Blurred fill</option>
<option value="mirror">Mirrored fill</option>
<option value="solid">Solid colour</option>
</select>
</div>
<div class="editor-row">
<label>Fill colour</label>
<input type="color" id="editorFillColor" value="#101318">
</div>
<div class="editor-row">
<label>JPEG quality <span id="editorQualityVal">95</span></label>
<input type="range" id="editorQualitySlider" min="80" max="98" value="95">
</div>
<div class="editor-actions">
<button class="btn btn-primary" id="btnEditorDownload" type="button" disabled>Download</button>
<button class="btn btn-green" id="btnEditorApply" type="button" disabled>Apply to Emby</button>
</div>
<p class="editor-note" id="editorExportMeta" style="margin-top:12px">Exports are cached and uploaded to the matching Emby image type.</p>
</div>
</div>
</section>
2026-04-15 09:27:29 +12:00
<!-- 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="solid">Solid colour</option>
</select>
</div>
<!-- BG colour (solid only) -->
<div class="ctrl-group" id="bgColorGroup" style="display:none">
<label>BG colour</label>
<div class="color-wrap">
<input type="color" id="bgColorPicker" value="#1a1a2e">
<input type="text" id="bgColorText" value="#1a1a2e" maxlength="7">
</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>
<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>
2026-04-15 21:25:03 +12:00
<input type="range" id="primaryZoomSlider" min="100" max="260" value="100">
2026-04-15 09:27:29 +12:00
<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-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>
</div>
</div>
</main>
</div>
2026-04-15 21:25:03 +12:00
<div class="modal-backdrop" id="imageSearchModal">
<div class="modal-card">
<div class="modal-head">
<div>
<div class="editor-title">Search Full-Size Artwork</div>
<div class="editor-subtitle">Find a better poster, still, or backdrop and import it into the editor cache.</div>
</div>
<button class="btn btn-ghost" id="btnCloseImageSearch" type="button">Close</button>
</div>
<div class="modal-body">
<div class="editor-grid" style="grid-template-columns: 1fr 180px auto; padding:0">
<div class="editor-row" style="margin:0">
<label>Search query</label>
<input type="text" id="imageSearchInput" placeholder="Movie or show title artwork">
</div>
<div class="editor-row" style="margin:0">
<label>Provider</label>
<select id="imageProviderSelect"></select>
</div>
<div class="editor-row" style="margin:0; align-self:end">
<button class="btn btn-primary" id="btnRunImageSearch" type="button">Search</button>
</div>
</div>
<div class="image-search-grid" id="imageSearchResults"></div>
</div>
</div>
</div>
2026-04-15 09:27:29 +12:00
<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,
searchQuery: '',
searchStart: 0,
searchLimit: 12,
searchTotal: 0,
searchController: null,
};
2026-04-15 21:25:03 +12:00
const editorState = {
sourceKind: 'emby',
sourceType: 'Primary',
sourceIndex: null,
importId: null,
cacheKey: null,
previewUrl: null,
imported: null,
drag: null,
providersLoaded: false,
};
2026-04-15 09:27:29 +12:00
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');
2026-04-15 21:25:03 +12:00
const btnPrimaryPreviewZoomOut = $('#btnPrimaryPreviewZoomOut');
const btnPrimaryPreviewZoomIn = $('#btnPrimaryPreviewZoomIn');
const artworkEditor = $('#artworkEditor');
const editorSubtitle = $('#editorSubtitle');
const editorSourceList = $('#editorSourceList');
const editorImportNote = $('#editorImportNote');
const editorCanvas = $('#editorCanvas');
const editorPreviewImg = $('#editorPreviewImg');
const editorCanvasEmpty = $('#editorCanvasEmpty');
const editorTargetSelect = $('#editorTargetSelect');
const editorFitSelect = $('#editorFitSelect');
const editorFillSelect = $('#editorFillSelect');
const editorFillColor = $('#editorFillColor');
const editorQualitySlider = $('#editorQualitySlider');
const editorQualityVal = $('#editorQualityVal');
const editorZoomSlider = $('#editorZoomSlider');
const editorZoomVal = $('#editorZoomVal');
const editorPanXSlider = $('#editorPanXSlider');
const editorPanXVal = $('#editorPanXVal');
const editorPanYSlider = $('#editorPanYSlider');
const editorPanYVal = $('#editorPanYVal');
const btnEditorPreview = $('#btnEditorPreview');
const btnEditorDownload = $('#btnEditorDownload');
const btnEditorApply = $('#btnEditorApply');
const btnEditorReset = $('#btnEditorReset');
const btnEditorZoomOut = $('#btnEditorZoomOut');
const btnEditorZoomIn = $('#btnEditorZoomIn');
const editorExportMeta = $('#editorExportMeta');
const btnOpenImageSearch = $('#btnOpenImageSearch');
const imageSearchModal = $('#imageSearchModal');
const btnCloseImageSearch = $('#btnCloseImageSearch');
const imageSearchInput = $('#imageSearchInput');
const imageProviderSelect = $('#imageProviderSelect');
const btnRunImageSearch = $('#btnRunImageSearch');
const imageSearchResults = $('#imageSearchResults');
2026-04-15 09:27:29 +12:00
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 bgColorGroup = $('#bgColorGroup');
const bgColorPicker= $('#bgColorPicker');
const bgColorText = $('#bgColorText');
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 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 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';
}
})();
// ── 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() {
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 ?? [];
logoAssetGroup.style.display = logos.length ? '' : 'none';
logoAssetCount.textContent = logos.length ? `${state.logoIndex + 1}/${logos.length}` : '0/0';
btnLogoPrev.disabled = logos.length <= 1 || state.logoIndex === 0;
btnLogoNext.disabled = logos.length <= 1 || state.logoIndex >= logos.length - 1;
if (logos.length) {
logoAssetPreview.classList.remove('empty');
logoAssetPreview.innerHTML = `<img src="${logos[state.logoIndex].url}" alt="" loading="lazy" decoding="async">`;
} else {
logoAssetPreview.classList.add('empty');
logoAssetPreview.textContent = 'No logo';
}
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 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 generateController = null;
let generateRequestId = 0;
let primaryDragState = null;
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function setPrimaryCropValues({ zoom, panX, panY }, { regenerate = false } = {}) {
2026-04-15 21:25:03 +12:00
if (zoom != null) primaryZoomSlider.value = String(clamp(Math.round(zoom), 100, 260));
2026-04-15 09:27:29 +12:00
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 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;
}
2026-04-15 21:25:03 +12:00
function nudgePrimaryZoom(delta) {
if (!state.item || !matchPrimaryToggle.checked) return;
setPrimaryCropValues({
zoom: Number(primaryZoomSlider.value) + delta,
}, { regenerate: true });
}
function updateEditorLabels() {
editorZoomVal.textContent = `${editorZoomSlider.value}%`;
editorPanXVal.textContent = editorPanXSlider.value;
editorPanYVal.textContent = editorPanYSlider.value;
editorQualityVal.textContent = editorQualitySlider.value;
editorCanvas.classList.toggle('thumb', editorTargetSelect.value === 'thumb');
editorCanvas.classList.toggle('backdrop', editorTargetSelect.value === 'backdrop');
}
function editorPayload() {
const effectiveSourceIndex = editorState.sourceType === 'Backdrop'
? (currentBackdropAsset()?.index ?? state.backdropIndex)
: editorState.sourceIndex;
return {
item_id: state.item?.id,
source_kind: editorState.sourceKind,
source_type: editorState.sourceType,
source_index: effectiveSourceIndex,
import_id: editorState.importId,
target_mode: editorTargetSelect.value,
fit_mode: editorFitSelect.value,
fill_mode: editorFillSelect.value,
fill_color: editorFillColor.value,
zoom: Number(editorZoomSlider.value) / 100,
pan_x: Number(editorPanXSlider.value) / 100,
pan_y: Number(editorPanYSlider.value) / 100,
quality: Number(editorQualitySlider.value),
};
}
function resetEditorTransform({ preview = false } = {}) {
editorZoomSlider.value = '100';
editorPanXSlider.value = '0';
editorPanYSlider.value = '0';
updateEditorLabels();
if (preview) scheduleEditorPreview();
}
function setEditorSource(button) {
editorSourceList.querySelectorAll('.editor-source-btn').forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
editorState.sourceKind = button.dataset.sourceKind;
editorState.sourceType = button.dataset.sourceType || 'Primary';
editorState.sourceIndex = button.dataset.sourceIndex ? Number(button.dataset.sourceIndex) : null;
editorState.importId = button.dataset.importId || null;
editorState.cacheKey = null;
resetEditorTransform();
scheduleEditorPreview();
}
function syncArtworkEditorForItem() {
const visible = Boolean(state.item);
artworkEditor.classList.toggle('visible', visible);
if (!visible) return;
editorSubtitle.textContent = `Editing artwork for ${state.item.name}`;
imageSearchInput.value = `${state.item.name} ${state.item.year || ''} poster backdrop`.trim();
editorSourceList.querySelectorAll('.editor-source-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.sourceKind === editorState.sourceKind && btn.dataset.sourceType === editorState.sourceType && !btn.dataset.importId);
});
scheduleEditorPreview();
}
function appendImportedSource(imported) {
editorState.imported = imported;
let btn = editorSourceList.querySelector('[data-source-kind="import"]');
if (!btn) {
btn = document.createElement('button');
btn.className = 'editor-source-btn';
btn.type = 'button';
btn.dataset.sourceKind = 'import';
editorSourceList.appendChild(btn);
btn.addEventListener('click', () => setEditorSource(btn));
}
btn.dataset.importId = imported.id;
btn.textContent = `Imported image ${imported.width}x${imported.height}`;
editorImportNote.textContent = `Imported ${imported.format} image cached locally for this editor session.`;
setEditorSource(btn);
}
let editorPreviewTimer;
async function generateEditorPreview() {
if (!state.item) return;
updateEditorLabels();
btnEditorPreview.disabled = true;
btnEditorApply.disabled = true;
btnEditorDownload.disabled = true;
try {
const r = await fetch('/api/artwork/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editorPayload()),
});
if (!r.ok) throw new Error(`${r.status}`);
editorState.cacheKey = r.headers.get('X-Editor-Cache-Key');
const blob = await r.blob();
if (editorState.previewUrl) URL.revokeObjectURL(editorState.previewUrl);
editorState.previewUrl = URL.createObjectURL(blob);
editorPreviewImg.src = editorState.previewUrl;
editorCanvasEmpty.style.display = 'none';
editorExportMeta.textContent = `${r.headers.get('X-Editor-Emby-Type')} export · ${r.headers.get('X-Editor-Width')}x${r.headers.get('X-Editor-Height')} · cached`;
btnEditorApply.disabled = false;
btnEditorDownload.disabled = false;
} catch (e) {
showToast(`Editor preview failed: ${e.message}`, 'err');
} finally {
btnEditorPreview.disabled = false;
}
}
function scheduleEditorPreview() {
if (!state.item) return;
clearTimeout(editorPreviewTimer);
editorPreviewTimer = setTimeout(generateEditorPreview, 220);
}
async function loadImageProviders() {
if (editorState.providersLoaded) return;
const r = await fetch('/api/artwork/providers');
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
imageProviderSelect.innerHTML = data.providers.map(provider => `<option value="${provider.key}">${esc(provider.label)}</option>`).join('');
editorState.providersLoaded = true;
}
async function runImageSearch() {
const q = imageSearchInput.value.trim();
if (q.length < 2) return;
imageSearchResults.innerHTML = '<div class="empty">Searching...</div>';
try {
const url = `/api/artwork/search?q=${encodeURIComponent(q)}&provider=${encodeURIComponent(imageProviderSelect.value)}&limit=12`;
const r = await fetch(url);
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
if (!data.items.length) {
imageSearchResults.innerHTML = '<div class="empty">No images found</div>';
return;
}
imageSearchResults.innerHTML = data.items.map(item => `
<button class="image-result" type="button" data-image-url="${esc(item.image_url)}">
<img src="${esc(item.thumbnail_url)}" alt="">
<div>${esc(item.title)}${item.width && item.height ? `<br>${item.width}x${item.height}` : ''}</div>
</button>
`).join('');
imageSearchResults.querySelectorAll('.image-result').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
const r = await fetch('/api/artwork/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_url: btn.dataset.imageUrl }),
});
if (!r.ok) throw new Error(`${r.status}`);
appendImportedSource(await r.json());
imageSearchModal.classList.remove('visible');
showToast('Imported image into editor', 'ok');
} catch (e) {
showToast(`Import failed: ${e.message}`, 'err');
} finally {
btn.disabled = false;
}
});
});
} catch (e) {
imageSearchResults.innerHTML = '<div class="empty">Search failed</div>';
showToast(`Image search failed: ${e.message}`, 'err');
}
}
2026-04-15 09:27:29 +12:00
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(`${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 = 0;
state.imageInfo = { logos: [], backdrops: [], primaries: [] };
state.generated = false;
state.cacheKey = null;
state.primaryGenerated = false;
2026-04-15 21:25:03 +12:00
editorState.sourceKind = 'emby';
editorState.sourceType = 'Primary';
editorState.sourceIndex = null;
editorState.importId = null;
editorState.cacheKey = null;
editorImportNote.textContent = 'Remote imports will appear here after you search and import a full-size image.';
editorSourceList.querySelector('[data-source-kind="import"]')?.remove();
resetEditorTransform();
2026-04-15 09:27:29 +12:00
primaryZoomSlider.value = '100';
primaryPanXSlider.value = '0';
primaryPanYSlider.value = '-16';
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');
2026-04-15 21:25:03 +12:00
syncArtworkEditorForItem();
2026-04-15 09:27:29 +12:00
btnRegenerate.disabled = true;
btnApply.disabled = true;
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;
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();
2026-04-15 21:25:03 +12:00
if (editorState.sourceKind === 'emby' && editorState.sourceType === 'Backdrop') scheduleEditorPreview();
2026-04-15 09:27:29 +12:00
generateThumb();
}
function stepLogo(delta) {
const max = (state.imageInfo.logos.length || 0) - 1;
if (max < 0) return;
const nextIndex = Math.max(0, 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;
});
});
// ── BG select ──
bgSelect.addEventListener('change', () => {
bgColorGroup.style.display = bgSelect.value === 'solid' ? '' : 'none';
});
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;
});
});
bgColorPicker.addEventListener('input', () => bgColorText.value = bgColorPicker.value);
bgColorText.addEventListener('input', () => { if (/^#[0-9a-f]{6}$/i.test(bgColorText.value)) bgColorPicker.value = bgColorText.value; });
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);
2026-04-15 21:25:03 +12:00
editorSourceList.querySelectorAll('.editor-source-btn').forEach(btn => {
btn.addEventListener('click', () => setEditorSource(btn));
});
editorTargetSelect.addEventListener('change', () => {
resetEditorTransform();
scheduleEditorPreview();
});
editorFitSelect.addEventListener('change', scheduleEditorPreview);
editorFillSelect.addEventListener('change', scheduleEditorPreview);
editorFillColor.addEventListener('input', scheduleEditorPreview);
editorQualitySlider.addEventListener('input', () => {
updateEditorLabels();
scheduleEditorPreview();
});
editorZoomSlider.addEventListener('input', scheduleEditorPreview);
editorPanXSlider.addEventListener('input', scheduleEditorPreview);
editorPanYSlider.addEventListener('input', scheduleEditorPreview);
btnEditorPreview.addEventListener('click', generateEditorPreview);
btnEditorReset.addEventListener('click', () => resetEditorTransform({ preview: true }));
btnEditorZoomOut.addEventListener('click', () => {
editorZoomSlider.value = String(clamp(Number(editorZoomSlider.value) - 10, 25, 500));
scheduleEditorPreview();
});
btnEditorZoomIn.addEventListener('click', () => {
editorZoomSlider.value = String(clamp(Number(editorZoomSlider.value) + 10, 25, 500));
scheduleEditorPreview();
});
btnEditorDownload.addEventListener('click', () => {
if (!editorState.cacheKey) return;
const a = document.createElement('a');
a.href = `/api/artwork/export/${editorState.cacheKey}`;
a.download = `${state.item?.name || 'artwork'}-${editorTargetSelect.value}.jpg`;
a.click();
});
btnEditorApply.addEventListener('click', async () => {
if (!state.item) return;
btnEditorApply.disabled = true;
try {
const r = await fetch('/api/artwork/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...editorPayload(), cache_key: editorState.cacheKey }),
});
if (!r.ok) throw new Error(`${r.status}`);
const data = await r.json();
showToast(`Applied ${data.emby_type} artwork`, 'ok');
} catch (e) {
showToast(`Apply failed: ${e.message}`, 'err');
} finally {
btnEditorApply.disabled = false;
}
});
btnOpenImageSearch.addEventListener('click', async () => {
try {
await loadImageProviders();
imageSearchModal.classList.add('visible');
} catch (e) {
showToast(`Could not load image providers: ${e.message}`, 'err');
}
});
btnCloseImageSearch.addEventListener('click', () => imageSearchModal.classList.remove('visible'));
imageSearchModal.addEventListener('click', event => {
if (event.target === imageSearchModal) imageSearchModal.classList.remove('visible');
});
btnRunImageSearch.addEventListener('click', runImageSearch);
imageSearchInput.addEventListener('keydown', event => {
if (event.key === 'Enter') runImageSearch();
});
2026-04-15 09:27:29 +12:00
btnPrimaryZoomOut.addEventListener('click', () => {
2026-04-15 21:25:03 +12:00
nudgePrimaryZoom(-10);
2026-04-15 09:27:29 +12:00
});
btnPrimaryZoomIn.addEventListener('click', () => {
2026-04-15 21:25:03 +12:00
nudgePrimaryZoom(10);
2026-04-15 09:27:29 +12:00
});
2026-04-15 21:25:03 +12:00
btnPrimaryPreviewZoomOut.addEventListener('click', () => nudgePrimaryZoom(-10));
btnPrimaryPreviewZoomIn.addEventListener('click', () => nudgePrimaryZoom(10));
2026-04-15 09:27:29 +12:00
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;
2026-04-15 21:25:03 +12:00
nudgePrimaryZoom(delta);
}, { passive: false });
editorCanvas.addEventListener('pointerdown', event => {
if (!state.item) return;
event.preventDefault();
editorState.drag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startPanX: Number(editorPanXSlider.value),
startPanY: Number(editorPanYSlider.value),
};
editorCanvas.classList.add('dragging');
editorCanvas.setPointerCapture(event.pointerId);
});
editorCanvas.addEventListener('pointermove', event => {
if (!editorState.drag || editorState.drag.pointerId !== event.pointerId) return;
const rect = editorCanvas.getBoundingClientRect();
editorPanXSlider.value = String(clamp(Math.round(editorState.drag.startPanX - ((event.clientX - editorState.drag.startX) / Math.max(1, rect.width)) * 200), -100, 100));
editorPanYSlider.value = String(clamp(Math.round(editorState.drag.startPanY - ((event.clientY - editorState.drag.startY) / Math.max(1, rect.height)) * 200), -100, 100));
scheduleEditorPreview();
});
function finishEditorDrag(pointerId = null) {
if (!editorState.drag) return;
if (pointerId != null && editorState.drag.pointerId !== pointerId) return;
try {
editorCanvas.releasePointerCapture(editorState.drag.pointerId);
} catch {}
editorCanvas.classList.remove('dragging');
editorState.drag = null;
}
editorCanvas.addEventListener('pointerup', event => finishEditorDrag(event.pointerId));
editorCanvas.addEventListener('pointercancel', event => finishEditorDrag(event.pointerId));
editorCanvas.addEventListener('lostpointercapture', () => finishEditorDrag());
editorCanvas.addEventListener('wheel', event => {
if (!state.item) return;
event.preventDefault();
const delta = event.deltaY < 0 ? 8 : -8;
editorZoomSlider.value = String(clamp(Number(editorZoomSlider.value) + delta, 25, 500));
scheduleEditorPreview();
2026-04-15 09:27:29 +12:00
}, { passive: false });
updatePrimaryPreviewMeta();
2026-04-15 21:25:03 +12:00
updateEditorLabels();
2026-04-15 09:27:29 +12:00
// ── Generate ──
async function generateThumb({ silent = false } = {}) {
if (!state.item) return;
clearTimeout(primaryPreviewTimer);
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,
custom_bg: bgSelect.value === 'solid' ? bgColorText.value : 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,
backdrop_index: currentBackdropAsset()?.index ?? state.backdropIndex,
}),
});
if (!r.ok) throw new Error(`${r.status}`);
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);
// ── 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,
custom_bg: bgSelect.value === 'solid' ? bgColorText.value : 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,
backdrop_index: currentBackdropAsset()?.index ?? state.backdropIndex,
}),
});
if (!r.ok) throw new Error(`${r.status}`);
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;
}
});
// ── 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);
}
</script>
</body>
</html>