updates
This commit is contained in:
+684
-7
@@ -311,6 +311,30 @@
|
||||
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;
|
||||
@@ -338,6 +362,195 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: var(--r-lg);
|
||||
@@ -846,6 +1059,10 @@
|
||||
</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>
|
||||
@@ -854,6 +1071,97 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Controls bar -->
|
||||
<div class="controls" id="controlsBar">
|
||||
<div class="ctrl-row">
|
||||
@@ -993,7 +1301,7 @@
|
||||
<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="60" max="260" value="100">
|
||||
<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>
|
||||
@@ -1039,6 +1347,34 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
@@ -1057,6 +1393,18 @@ const state = {
|
||||
searchController: null,
|
||||
};
|
||||
|
||||
const editorState = {
|
||||
sourceKind: 'emby',
|
||||
sourceType: 'Primary',
|
||||
sourceIndex: null,
|
||||
importId: null,
|
||||
cacheKey: null,
|
||||
previewUrl: null,
|
||||
imported: null,
|
||||
drag: null,
|
||||
providersLoaded: false,
|
||||
};
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
const searchInput = $('#searchInput');
|
||||
const resultsToolbar = $('#resultsToolbar');
|
||||
@@ -1075,6 +1423,41 @@ const primaryPreviewImg = $('#primaryPreviewImg');
|
||||
const primaryPreviewEmpty = $('#primaryPreviewEmpty');
|
||||
const primaryPreviewMeta = $('#primaryPreviewMeta');
|
||||
const btnPrimaryReset = $('#btnPrimaryReset');
|
||||
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');
|
||||
const loadingCover = $('#loadingCover');
|
||||
const bdNav = $('#bdNav');
|
||||
const bdCounter = $('#bdCounter');
|
||||
@@ -1258,7 +1641,7 @@ function clamp(n, min, max) {
|
||||
}
|
||||
|
||||
function setPrimaryCropValues({ zoom, panX, panY }, { regenerate = false } = {}) {
|
||||
if (zoom != null) primaryZoomSlider.value = String(clamp(Math.round(zoom), 60, 260));
|
||||
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();
|
||||
@@ -1287,6 +1670,182 @@ function finishPrimaryDrag(pointerId = null) {
|
||||
primaryDragState = null;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch(q, start = 0) {
|
||||
if (state.searchController) state.searchController.abort();
|
||||
const controller = new AbortController();
|
||||
@@ -1364,6 +1923,14 @@ async function selectItem(item) {
|
||||
state.generated = false;
|
||||
state.cacheKey = null;
|
||||
state.primaryGenerated = false;
|
||||
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();
|
||||
primaryZoomSlider.value = '100';
|
||||
primaryPanXSlider.value = '0';
|
||||
primaryPanYSlider.value = '-16';
|
||||
@@ -1375,6 +1942,7 @@ async function selectItem(item) {
|
||||
|
||||
placeholder.style.display = 'none';
|
||||
controlsBar.classList.add('visible');
|
||||
syncArtworkEditorForItem();
|
||||
btnRegenerate.disabled = true;
|
||||
btnApply.disabled = true;
|
||||
bdCounter.style.display = 'none';
|
||||
@@ -1431,6 +1999,7 @@ function stepBackdrop(delta) {
|
||||
state.backdropIndex = nextIndex;
|
||||
updateAssetPickers();
|
||||
updateBdUI();
|
||||
if (editorState.sourceKind === 'emby' && editorState.sourceType === 'Backdrop') scheduleEditorPreview();
|
||||
generateThumb();
|
||||
}
|
||||
|
||||
@@ -1502,12 +2071,82 @@ matchPrimaryToggle.addEventListener('change', () => {
|
||||
primaryZoomSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
||||
primaryPanXSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
||||
primaryPanYSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
||||
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();
|
||||
});
|
||||
btnPrimaryZoomOut.addEventListener('click', () => {
|
||||
setPrimaryCropValues({ zoom: Number(primaryZoomSlider.value) - 10 }, { regenerate: true });
|
||||
nudgePrimaryZoom(-10);
|
||||
});
|
||||
btnPrimaryZoomIn.addEventListener('click', () => {
|
||||
setPrimaryCropValues({ zoom: Number(primaryZoomSlider.value) + 10 }, { regenerate: true });
|
||||
nudgePrimaryZoom(10);
|
||||
});
|
||||
btnPrimaryPreviewZoomOut.addEventListener('click', () => nudgePrimaryZoom(-10));
|
||||
btnPrimaryPreviewZoomIn.addEventListener('click', () => nudgePrimaryZoom(10));
|
||||
btnPrimaryReset.addEventListener('click', () => {
|
||||
setPrimaryCropValues({ zoom: 100, panX: 0, panY: -16 }, { regenerate: true });
|
||||
});
|
||||
@@ -1541,11 +2180,49 @@ primaryPreviewFrame.addEventListener('wheel', event => {
|
||||
if (!state.item || !matchPrimaryToggle.checked) return;
|
||||
event.preventDefault();
|
||||
const delta = event.deltaY < 0 ? 8 : -8;
|
||||
setPrimaryCropValues({
|
||||
zoom: Number(primaryZoomSlider.value) + delta,
|
||||
}, { regenerate: true });
|
||||
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();
|
||||
}, { passive: false });
|
||||
updatePrimaryPreviewMeta();
|
||||
updateEditorLabels();
|
||||
|
||||
// ── Generate ──
|
||||
async function generateThumb({ silent = false } = {}) {
|
||||
|
||||
Reference in New Issue
Block a user