Meine Kamera
iOS Kamera
eigene Kamera
Format
Bilder
Auflösung
Denken
+
Motor wartet.
Sucher aktiv 16:9
vor ). * Fallback: URL-Parameter ?t=TOKEN */ const FRIENDS_USER = window.FRIENDS_USER || {}; const FRIENDS_TOKEN = window.FRIENDS_TOKEN || new URLSearchParams(location.search).get('t') || ''; const API_PREFIX = '/f'; /* Hilfsfunktion: API-URL mit Token-Anhang */ function fApiUrl(path) { const sep = path.includes('?') ? '&' : '?'; return API_PREFIX + path + sep + 't=' + encodeURIComponent(FRIENDS_TOKEN); } /* Kompatibilitäts-Shim: API bleibt leer für Pfade die nicht /api/kameramotor/* sind */ const API = ''; /* Filter-Übersetzungen */ const FILTER_LABELS = { 'Exploder': 'EXPLODIERT', 'Food Analyzer': 'LEBENSMITTEL', 'Gaudify': 'GAUDÍ', 'Homestead': 'HOMESTEAD', 'Humanize': 'VERMENSCHT', 'Metapher': 'METAPHER', 'Museum': 'MUSEUM', 'Neatify': 'AUFGERÄUMT', 'Opposite': 'GEGENTEIL', 'Secrets': 'GEHEIMNISSE', 'Tanaka': 'TANAKA', 'Time Travel - 100 Years Back': 'VOR 100 JAHREN', 'Time Travel - 50 Years Back': 'VOR 50 JAHREN', 'Time Travel - Bright Future': 'IN 50 JAHREN', 'Time Travel - Dark Future': 'IN 100 JAHREN' }; /* Same labels for the filter pills (folder name → display) */ const PILL_LABELS = { 'Time Travel - 100 Years Back': 'Vor 100 Jahren', 'Time Travel - 50 Years Back': 'Vor 50 Jahren', 'Time Travel - Bright Future': 'In 50 Jahren', 'Time Travel - Dark Future': 'In 100 Jahren' }; /* State */ let allFilters = [], selectedFilterPaths = [], selectedRatio = 'auto'; let pendingFile = null; let isSubmitting = false; // verhindert Doppel-Submit während generateAll läuft let filtersExpanded = false; const PINNED_COUNT = 10; const isIphone = /iPhone/.test(navigator.userAgent); /* Library für Fullscreen */ let library = [], libraryIdx = 0; /* Favorites — persisted in localStorage (pro User isoliert) */ var _favsKey = 'friends_favs_' + (FRIENDS_USER.id || 'guest'); var favItems = JSON.parse(localStorage.getItem(_favsKey) || '[]'); var favSet = new Set(favItems.map(function(f){ return f.url; })); /* Stable queue DOM map */ const _qMap = new Map(); let _qList = null; let _qDonePage = 1; /* ───────────────────────────────────────────────────────────────────────── * FULLSCREEN: iOS-Photos-Style Carousel * * Architecture: * - 3 Panels (prev/cur/next), Track 300% wide, normally translateX(-100%). * - 1-Finger horizontal swipe → animate to neighbour, then rotate panels. * - Pinch → zoom current panel image (cur-img transform only). * - Compare button → toggle compareMode. * In compareMode, 1-finger drag slides cur-img horizontally * (cmpX offset). Original-img stays behind, visible. * Pinch cancels compareMode immediately. * - Contact-strip at bottom: tap thumb → jump to that index. * ───────────────────────────────────────────────────────────────────────── */ const Fullscreen = (function() { let ov, carousel, track, curPanel, curImg, prevImg, nextImg, ori, strip, hint, sliderHandle, sliderBtn, favBtn; /* Zoom state */ let s = 1, tx = 0, ty = 0; /* Slider compare state: sliderFrac 0=all-original … 1=all-processed */ let sliderMode = false, sliderFrac = 1; /* Touch state */ let touchPhase = null; // 'swipe' | 'pan' | 'pinch' | 'slider' let startX = 0, startY = 0, lastX = 0, lastY = 0; /* Pinch: absolute-start values so incremental drift never accumulates */ let twoStartDist = 1, twoMidX = 0, twoMidY = 0, twoStartS = 1, twoStartTx = 0, twoStartTy = 0; let ptx = 0, pty = 0; let moved = false, lastTap = 0; let dragOffsetPct = 0; let velX = 0, lastMoveTime = 0, lastMoveX = 0; let navigating = false; function init() { ov = document.getElementById('fullscreen-overlay'); carousel = document.getElementById('fs-carousel'); track = document.getElementById('fs-track'); const panels = track.querySelectorAll('.fs-panel'); prevImg = panels[0].querySelector('.fs-panel-img'); curPanel = panels[1]; curImg = panels[1].querySelector('.fs-panel-img'); nextImg = panels[2].querySelector('.fs-panel-img'); ori = document.getElementById('original-img'); strip = document.getElementById('fs-strip'); hint = document.getElementById('fs-compare-hint'); sliderHandle = document.getElementById('fs-slider-handle'); sliderBtn = document.getElementById('fs-slider-btn'); favBtn = document.getElementById('fs-fav-btn'); function bindBtn(el, fn) { el.addEventListener('click', function(e){ e.stopPropagation(); fn(); }); el.addEventListener('touchend', function(e){ e.stopPropagation(); e.preventDefault(); fn(); }, {passive:false}); } bindBtn(sliderBtn, toggleSlider); bindBtn(favBtn, toggleFav); carousel.addEventListener('touchstart', onTouchStart, {passive:false}); carousel.addEventListener('touchmove', onTouchMove, {passive:false}); carousel.addEventListener('touchend', onTouchEnd, {passive:false}); document.addEventListener('keydown', function(e){ if (!ov.classList.contains('visible')) return; if (e.key === 'Escape') close(); if (e.key === 'ArrowLeft') navigate(-1); if (e.key === 'ArrowRight') navigate(1); }); } function dd(a, b){ return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); } /* Max translation for current scale — offsetWidth/Height is layout size (transform-independent) */ function maxPan() { const W = carousel.clientWidth, H = carousel.clientHeight; const dW = curImg.offsetWidth || W, dH = curImg.offsetHeight || H; return { mx: Math.max(0, (dW * s - W) / 2), my: Math.max(0, (dH * s - H) / 2) }; } /* Clamp with optional rubber-band resistance beyond limits */ function rc(v, lim, bounce) { if (!bounce) return Math.max(-lim, Math.min(lim, v)); if (v > lim) return lim + (v - lim) * 0.3; if (v < -lim) return -lim + (v + lim) * 0.3; return v; } /* Apply zoom transform to curImg and, when slider is active, also sync ori + handle + clip */ function applyTransforms(animate) { const tr = animate ? 'transform .3s cubic-bezier(.4,0,.2,1)' : 'none'; if (sliderMode) { const W = carousel.clientWidth; const dW = curImg.offsetWidth || W; const clipR = ((1 - sliderFrac) * 100).toFixed(2); const ctr = animate ? 'transform .3s cubic-bezier(.4,0,.2,1), clip-path .3s cubic-bezier(.4,0,.2,1)' : 'none'; curImg.style.transition = ctr; curImg.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + s + ')'; curImg.style.clipPath = 'inset(0 ' + clipR + '% 0 0)'; ori.style.transition = tr; ori.style.transform = 'translate(calc(-50% + ' + tx + 'px), calc(-50% + ' + ty + 'px)) scale(' + s + ')'; /* Handle screen-x: where the clip boundary falls */ const hx = W / 2 + tx + dW * s * (sliderFrac - 0.5); sliderHandle.style.transition = animate ? 'left .3s cubic-bezier(.4,0,.2,1)' : 'none'; sliderHandle.style.left = hx.toFixed(1) + 'px'; } else { curImg.style.transition = tr; curImg.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + s + ')'; } } function resetCurTransform() { s = 1; tx = 0; ty = 0; curImg.style.transition = 'none'; curImg.style.transform = ''; if (!sliderMode) curImg.style.clipPath = ''; } /* Snap zoom to fit or clamp pan to valid bounds */ function snapBack() { if (s < 1.05) { s = 1; tx = 0; ty = 0; applyTransforms(true); return; } const { mx, my } = maxPan(); const cx = Math.max(-mx, Math.min(mx, tx)); const cy = Math.max(-my, Math.min(my, ty)); if (cx !== tx || cy !== ty) { tx = cx; ty = cy; applyTransforms(true); } } function applyTrackTranslate(extraPct, animate) { const w = carousel.clientWidth; const px = (-100 + extraPct) * w / 100; track.style.transition = animate ? 'transform .26s cubic-bezier(.33,1,.68,1)' : 'none'; track.style.transform = 'translateX(' + px + 'px)'; } function loadPanels() { if (!library.length) return; const n = library.length; if (libraryIdx > 0) { prevImg.src = library[libraryIdx-1].url; prevImg.style.visibility = 'visible'; } else { prevImg.removeAttribute('src'); prevImg.style.visibility = 'hidden'; } curImg.src = library[libraryIdx].url; if (libraryIdx < n-1) { nextImg.src = library[libraryIdx+1].url; nextImg.style.visibility = 'visible'; } else { nextImg.removeAttribute('src'); nextImg.style.visibility = 'hidden'; } const orig = library[libraryIdx].originalUrl; if (orig) ori.src = orig; else ori.removeAttribute('src'); if (!sliderMode) ori.classList.remove('cmp-visible'); } function rebuildStrip() { strip.innerHTML = ''; library.forEach(function(e, i) { const t = document.createElement('img'); t.className = 'fs-thumb' + (i === libraryIdx ? ' active' : ''); t.src = e.url; t.dataset.idx = i; t.addEventListener('click', function(ev){ ev.stopPropagation(); jumpTo(i); }); strip.appendChild(t); }); scrollStripToActive(false); } function updateStripActive() { const thumbs = strip.querySelectorAll('.fs-thumb'); thumbs.forEach(function(t) { t.classList.toggle('active', parseInt(t.dataset.idx) === libraryIdx); }); scrollStripToActive(true); } function scrollStripToActive(smooth) { const active = strip.querySelector('.fs-thumb.active'); if (!active) return; const sRect = strip.getBoundingClientRect(); const target = active.offsetLeft - sRect.width / 2 + active.offsetWidth / 2; strip.scrollTo({ left: target, behavior: smooth ? 'smooth' : 'auto' }); } function open(url, originalUrl) { if (!library.length) library = [{ url: url, originalUrl: originalUrl || null }]; libraryIdx = library.findIndex(function(e){ return e.url === url; }); if (libraryIdx < 0) libraryIdx = 0; resetCurTransform(); applyTrackTranslate(0, false); loadPanels(); rebuildStrip(); _updateFavBtn(); ov.classList.add('visible'); } function close() { if (sliderMode) _turnOffSlider(); ov.classList.remove('visible'); } function jumpTo(i) { if (!library.length) return; libraryIdx = ((i % library.length) + library.length) % library.length; resetCurTransform(); if (sliderMode) _initSlider(); applyTrackTranslate(0, false); loadPanels(); updateStripActive(); _updateFavBtn(); } function navigate(dir) { if (!library.length || navigating) return; const newIdx = libraryIdx + dir; if (newIdx < 0 || newIdx >= library.length) { applyTrackTranslate(0, true); return; } navigating = true; applyTrackTranslate(dir > 0 ? -100 : 100, true); function settle() { track.removeEventListener('transitionend', settle); libraryIdx = newIdx; resetCurTransform(); if (sliderMode) _initSlider(); applyTrackTranslate(0, false); loadPanels(); updateStripActive(); _updateFavBtn(); navigating = false; } track.addEventListener('transitionend', settle, { once: true }); setTimeout(function(){ if (navigating) settle(); }, 350); } /* ── Slider compare ──────────────────────────────────────────────────────── */ function toggleSlider() { if (sliderMode) { _turnOffSlider(); return; } const orig = library[libraryIdx] && library[libraryIdx].originalUrl; if (!orig) { hint.textContent = 'Kein Original vorhanden'; hint.classList.add('visible'); setTimeout(function(){ hint.classList.remove('visible'); hint.textContent = ''; }, 1500); return; } sliderMode = true; sliderBtn.classList.add('active'); _initSlider(); } function _initSlider() { const orig = library[libraryIdx] && library[libraryIdx].originalUrl; if (!orig) { _turnOffSlider(); return; } ori.src = orig; ori.classList.add('cmp-visible'); sliderHandle.classList.add('active'); sliderFrac = 0.5; applyTransforms(false); } function _turnOffSlider() { sliderMode = false; sliderFrac = 1; sliderBtn.classList.remove('active'); sliderHandle.classList.remove('active'); curImg.style.clipPath = ''; ori.classList.remove('cmp-visible'); ori.style.transition = 'none'; ori.style.transform = ''; curImg.style.transition = 'none'; curImg.style.transform = s !== 1 || tx !== 0 || ty !== 0 ? 'translate(' + tx + 'px,' + ty + 'px) scale(' + s + ')' : ''; } /* ── Favorites ───────────────────────────────────────────────────────────── */ function toggleFav() { if (!library.length) return; toggleFavEntry(library[libraryIdx]); _updateFavBtn(); } function _updateFavBtn() { if (!favBtn || !library.length) return; const on = favSet.has(library[libraryIdx].url); favBtn.classList.toggle('fav-on', on); favBtn.innerHTML = on ? '♥' : '♡'; } /* ── Touch handlers ──────────────────────────────────────────────────────── */ function onTouchStart(e) { e.preventDefault(); moved = false; const t = e.touches; if (t.length >= 2) { touchPhase = 'pinch'; const rect = carousel.getBoundingClientRect(); const W = carousel.clientWidth, H = carousel.clientHeight; twoStartDist = dd(t[0], t[1]); twoMidX = ((t[0].clientX + t[1].clientX) / 2) - rect.left - W/2; twoMidY = ((t[0].clientY + t[1].clientY) / 2) - rect.top - H/2; twoStartS = s; twoStartTx = tx; twoStartTy = ty; } else { startX = t[0].clientX; startY = t[0].clientY; lastX = startX; lastY = startY; ptx = tx; pty = ty; dragOffsetPct = 0; velX = 0; lastMoveTime = Date.now(); lastMoveX = startX; if (sliderMode) touchPhase = 'slider'; else if (s > 1) touchPhase = 'pan'; else touchPhase = 'swipe'; } } function onTouchMove(e) { e.preventDefault(); moved = true; const t = e.touches; const W = carousel.clientWidth, H = carousel.clientHeight; if (touchPhase === 'pinch' && t.length >= 2) { const nd = dd(t[0], t[1]); const newS = Math.max(1, Math.min(8, twoStartS * nd / twoStartDist)); const r = newS / twoStartS; tx = twoMidX * (1 - r) + twoStartTx * r; ty = twoMidY * (1 - r) + twoStartTy * r; s = newS; const { mx, my } = maxPan(); tx = rc(tx, mx, true); ty = rc(ty, my, true); applyTransforms(false); } else if (t.length === 1) { const cx = t[0].clientX, cy = t[0].clientY; lastX = cx; lastY = cy; if (touchPhase === 'slider') { const rect = carousel.getBoundingClientRect(); const dW = curImg.offsetWidth || W; const imgL = W/2 + tx - dW * s / 2; sliderFrac = Math.max(0, Math.min(1, (cx - rect.left - imgL) / (dW * s))); applyTransforms(false); } else if (touchPhase === 'pan') { const { mx, my } = maxPan(); tx = rc(ptx + (cx - startX), mx, true); ty = rc(pty + (cy - startY), my, true); applyTransforms(false); } else if (touchPhase === 'swipe') { const now2 = Date.now(), dt = now2 - lastMoveTime; if (dt > 0) velX = (cx - lastMoveX) / dt; lastMoveTime = now2; lastMoveX = cx; let pct = ((cx - startX) / W) * 100; if ((libraryIdx === 0 && pct > 0) || (libraryIdx === library.length - 1 && pct < 0)) pct *= 0.3; dragOffsetPct = pct; applyTrackTranslate(dragOffsetPct, false); } } } function _doubleTapZoom() { const W = carousel.clientWidth, H = carousel.clientHeight; const rect = carousel.getBoundingClientRect(); const relX = lastX - rect.left - W/2; const relY = lastY - rect.top - H/2; if (s > 1.5) { s = 1; tx = 0; ty = 0; } else { const newS = 2.5, r = newS / s; tx = relX * (1 - r) + tx * r; ty = relY * (1 - r) + ty * r; s = newS; const { mx, my } = maxPan(); tx = Math.max(-mx, Math.min(mx, tx)); ty = Math.max(-my, Math.min(my, ty)); } applyTransforms(true); } function onTouchEnd(e) { e.preventDefault(); const remaining = e.touches.length; const W = carousel.clientWidth; if (remaining > 0) { if (touchPhase === 'pinch') { startX = e.touches[0].clientX; startY = e.touches[0].clientY; ptx = tx; pty = ty; touchPhase = sliderMode ? 'slider' : (s > 1 ? 'pan' : 'swipe'); } return; } if (touchPhase === 'pinch') { snapBack(); } else if (touchPhase === 'slider') { if (!moved) { const now = Date.now(); if (now - lastTap < 280) { _doubleTapZoom(); lastTap = 0; } else lastTap = now; } else { sliderFrac = 1; applyTransforms(true); } } else if (touchPhase === 'swipe') { if (!moved) { const now = Date.now(); if (now - lastTap < 280) { _doubleTapZoom(); lastTap = 0; } else lastTap = now; } else { if (lastY - startY < -80 && Math.abs(lastX - startX) < 60) { close(); touchPhase = null; return; } const threshold = W * 0.18, FLICK = 0.28; const dx = lastX - startX; if (dx < -threshold || velX < -FLICK) navigate(1); else if (dx > threshold || velX > FLICK) navigate(-1); else applyTrackTranslate(0, true); } } else if (touchPhase === 'pan') { if (!moved) { const now = Date.now(); if (now - lastTap < 280) { s = 1; tx = 0; ty = 0; applyTransforms(true); lastTap = 0; } else lastTap = now; } else { snapBack(); } } touchPhase = null; } return { init: init, open: open, close: close, navigate: navigate, jumpTo: jumpTo }; })(); /* ── Favorites helpers ─────────────────────────────────────────────────── */ function toggleFavEntry(entry) { if (favSet.has(entry.url)) { favSet.delete(entry.url); favItems = favItems.filter(function(f){ return f.url !== entry.url; }); } else { favSet.add(entry.url); favItems.push({ url: entry.url, originalUrl: entry.originalUrl || null }); } localStorage.setItem(_favsKey, JSON.stringify(favItems)); buildFavsSection(); } function buildFavsSection() { var sec = document.getElementById('sec-fav'); var grid = document.getElementById('fav-grid'); if (!sec || !grid) return; grid.innerHTML = ''; if (!favItems.length) { sec.classList.remove('has-items'); return; } sec.classList.add('has-items'); favItems.forEach(function(f, idx) { var cell = document.createElement('div'); cell.className = 'img-cell loaded fade-in'; var img = document.createElement('img'); img.src = f.url; img.loading = 'lazy'; cell.appendChild(img); cell.addEventListener('click', function() { library = favItems.map(function(x){ return { url: x.url, originalUrl: x.originalUrl || null }; }); Fullscreen.open(f.url, f.originalUrl); }); grid.appendChild(cell); }); } buildFavsSection(); function showFullscreen(url) { showFullscreenUrl(url, null); } function showFullscreenUrl(url, originalUrl) { Fullscreen.open(url, originalUrl); } function closeFullscreen() { Fullscreen.close(); } function buildLibrary(allDoneJobs) { library = []; allDoneJobs.forEach(function(job) { const iu = inputThumbUrl(job); const dlArr = job.downloaded ? (Array.isArray(job.downloaded) ? job.downloaded : Object.values(job.downloaded)) : []; dlArr.forEach(function(dl) { if (dl && dl.outPath) { const tu = thumbUrl(dl); if (tu) library.push({ url: tu, originalUrl: iu || null }); } }); }); } /* ── Settings ── */ const SETTINGS_KEY = 'friends_kameramotor_settings_v1_' + (FRIENDS_USER.id || 'guest'); const SAVE_URL = fApiUrl('/api/settings'); let saveTimer = null; function collectSettings() { return { ratio: selectedRatio, prompt: document.getElementById('prompt')?.value || '', filter: selectedFilterPaths, numImages: document.getElementById('s-num-images')?.value || '2', resolution: document.getElementById('s-resolution')?.value || '4k', thinking: document.getElementById('s-thinking')?.value || 'high', mode: 'imagen-nano-banana-2-flash', savedAt: new Date().toISOString() }; } function applySettings(st) { if (!st) return; if (st.ratio) { selectedRatio = st.ratio; const sel = document.getElementById('s-ratio'); if (sel) { sel.value = st.ratio; sel.classList.toggle('ratio-auto', st.ratio === 'auto'); } } if (st.prompt && document.getElementById('prompt')) { document.getElementById('prompt').value = st.prompt; if (st.prompt.trim()) { document.getElementById('custom-prompt-wrap')?.classList.add('visible'); document.getElementById('toggle-custom-prompt')?.classList.add('active'); } } if (st.resolution) document.getElementById('s-resolution').value = st.resolution; if (st.thinking) { document.getElementById('s-thinking').value = st.thinking; const c = document.getElementById('thinking-check'); if (c) c.checked = (st.thinking === 'high'); } } function scheduleSettingsSave() { localStorage.setItem(SETTINGS_KEY, JSON.stringify(collectSettings())); clearTimeout(saveTimer); saveTimer = setTimeout(function(){ fetch(fApiUrl('/api/settings'),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(collectSettings())}).catch(function(){}); }, 300); } function emergencySettingsSave() { const s = collectSettings(); localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); try { navigator.sendBeacon(SAVE_URL, new Blob([JSON.stringify(s)], {type:'application/json'})); } catch(e) {} } window.addEventListener('beforeunload', emergencySettingsSave); window.addEventListener('pagehide', emergencySettingsSave); async function loadSettings() { try { const r = await fetch(fApiUrl('/api/settings')); const d = await r.json(); if (d.ok && d.settings) { const sv = d.settings, lo = JSON.parse(localStorage.getItem(SETTINGS_KEY) || 'null'); applySettings((lo?.savedAt && sv.savedAt && lo.savedAt > sv.savedAt) ? lo : sv); return; } } catch(e) {} const lo = localStorage.getItem(SETTINGS_KEY); if (lo) try { applySettings(JSON.parse(lo)); } catch(e) {} } function onThinkingChange() { const c = document.getElementById('thinking-check'); document.getElementById('s-thinking').value = c.checked ? 'high' : 'normal'; scheduleSettingsSave(); } function onRatioChange() { const sel = document.getElementById('s-ratio'); selectedRatio = sel.value; sel.classList.toggle('ratio-auto', selectedRatio === 'auto'); } /* ── Image capture ── */ const fi = document.getElementById('file-input'); fi.addEventListener('change', function() { const file = fi.files[0]; if (!file) return; pendingFile = file; const url = URL.createObjectURL(file); document.getElementById('preview-img').src = url; document.getElementById('preview-frame').classList.add('visible'); fi.value = ''; updateDevelopBtn(); scheduleSettingsSave(); }); function updateDevelopBtn() { if (isSubmitting) return; // kein Re-enable während Submit läuft const btn = document.getElementById('generate-btn'); const n = parseInt(document.getElementById('s-num-images')?.value || '2'); const customPrompt = document.getElementById('prompt')?.value.trim() || ''; const eff = selectedFilterPaths.length > 0 ? selectedFilterPaths.length : (customPrompt ? 1 : 0); const total = n * eff; if (!pendingFile || eff === 0) { btn.disabled = true; btn.textContent = 'Entwickeln'; } else { btn.disabled = false; btn.textContent = total + ' Bild' + (total !== 1 ? 'er' : '') + ' entwickeln'; } } /* ── Filters ── */ async function loadFilters(silent) { try { const r = await fetch(fApiUrl('/api/filters')); const d = await r.json(); if (d.ok) { const sig = d.filters.map(function(f){ return f.name + ':' + !!f.has_prompt; }).join('|'); const prev = allFilters.map(function(f){ return f.name + ':' + !!f.has_prompt; }).join('|'); if (sig !== prev || !allFilters.length) { allFilters = d.filters; renderFilterPills(allFilters); } if (window._pendingFilterRestore !== undefined) { selectedFilterPaths = window._pendingFilterRestore.filter(function(p){ return allFilters.some(function(f){ return f.path === p; }); }); delete window._pendingFilterRestore; updateFilterPillStates(); updateDevelopBtn(); } } } catch(e) {} } function renderFilterPills(list) { /* Always show all filters, no search field */ const wrap = document.getElementById('filter-pills'); while (wrap.firstChild) wrap.removeChild(wrap.firstChild); const noneTxt = document.createTextNode('Kein'); const none = document.createElement('div'); none.className = 'filter-pill none' + (selectedFilterPaths.length === 0 ? ' active' : ''); none.dataset.path = ''; none.appendChild(noneTxt); none.addEventListener('click', function(){ toggleFilter(''); }); wrap.appendChild(none); list.forEach(function(f) { const noPrompt = !f.has_prompt && !f.path; const isActive = selectedFilterPaths.includes(f.path); const label = PILL_LABELS[f.name] || f.name; const p = document.createElement('div'); p.className = 'filter-pill' + (isActive ? ' active' : '') + (noPrompt ? ' no-prompt' : ''); p.appendChild(document.createTextNode(label)); p.dataset.path = f.path; p.title = noPrompt ? 'Kein Prompt.txt' : label; p.addEventListener('click', function(){ if (!noPrompt) toggleFilter(f.path); }); wrap.appendChild(p); }); } function updateFilterPillStates() { document.querySelectorAll('.filter-pill').forEach(function(p) { const path = p.dataset.path; const active = path === '' ? selectedFilterPaths.length === 0 : selectedFilterPaths.includes(path); p.classList.toggle('active', active); }); } function toggleFilter(path) { if (path === '') { selectedFilterPaths = []; } else { const i = selectedFilterPaths.indexOf(path); if (i >= 0) selectedFilterPaths.splice(i, 1); else selectedFilterPaths.push(path); } updateFilterPillStates(); updateDevelopBtn(); scheduleSettingsSave(); } function filterList() { const q = document.getElementById('filter-search')?.value.toLowerCase().trim() || ''; renderFilterPills(q ? allFilters.filter(function(f){ return f.name.toLowerCase().includes(q); }) : allFilters); } function toggleCustomPrompt() { document.getElementById('custom-prompt-wrap')?.classList.toggle('visible'); document.getElementById('toggle-custom-prompt')?.classList.toggle('active'); if (document.getElementById('custom-prompt-wrap')?.classList.contains('visible')) document.getElementById('prompt')?.focus(); } /* ── Generate ── */ async function generateAll() { if (!pendingFile || isSubmitting) return; isSubmitting = true; const customPrompt = document.getElementById('prompt')?.value.trim() || ''; const filterPaths = selectedFilterPaths.length > 0 ? selectedFilterPaths : (customPrompt ? [null] : []); if (!filterPaths.length) { isSubmitting = false; return; } const btn = document.getElementById('generate-btn'); btn.disabled = true; btn.textContent = '…'; const cnt = document.getElementById('submit-count'); cnt.textContent = ''; cnt.classList.remove('visible'); cnt.style.color = ''; const stem = pendingFile.name.replace(/\.[^.]+$/, ''); let imagePath = (pendingFile.path && pendingFile.path.startsWith('/')) ? pendingFile.path : null; if (!imagePath && pendingFile.size > 0) { try { const b64 = await new Promise(function(resolve, reject) { const reader = new FileReader(); reader.onload = function() { resolve(reader.result.split(',')[1]); }; reader.onerror = reject; reader.readAsDataURL(pendingFile); }); const ur = await fetch(fApiUrl('/api/upload'), { method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify({filename: pendingFile.name, data: b64}) }); const ud = await ur.json(); if (ud.ok) { imagePath = ud.path; } else if (ud.error === 'daily_limit' || ud.error === 'total_limit') { isSubmitting = false; btn.disabled = false; btn.textContent = 'Entwickeln'; const cnt2 = document.getElementById('submit-count'); cnt2.textContent = ud.error === 'daily_limit' ? 'Tageslimit erreicht (100/100)' : 'Gesamtlimit erreicht'; cnt2.style.color = 'var(--orange)'; cnt2.classList.add('visible'); loadLimits(); return; } else { isSubmitting = false; btn.disabled = false; btn.textContent = 'Entwickeln'; const cnt2 = document.getElementById('submit-count'); cnt2.textContent = 'Upload fehlgeschlagen: ' + (ud.error || 'unbekannt'); cnt2.style.color = 'var(--red, #e55)'; cnt2.classList.add('visible'); return; } } catch(e) { isSubmitting = false; btn.disabled = false; btn.textContent = 'Entwickeln'; const cnt = document.getElementById('submit-count'); cnt.textContent = 'Upload-Fehler: ' + e.message; cnt.style.color = 'var(--red, #e55)'; cnt.classList.add('visible'); return; } } let submitted = 0; for (let i = 0; i < filterPaths.length; i++) { const filterPath = filterPaths[i]; /* output_dir wird vom Friends-Server gesetzt — wir geben nur stem an */ const body = { ratio: selectedRatio, num_images: parseInt(document.getElementById('s-num-images')?.value || '2'), resolution: document.getElementById('s-resolution')?.value || '4k', thinking_level: document.getElementById('s-thinking')?.value || 'high', mode: 'imagen-nano-banana-2-flash', stem: stem, }; if (imagePath) body.image = imagePath; if (filterPath) body.prompt_file = filterPath; else if (customPrompt) body.prompt = customPrompt; try { const r = await fetch(fApiUrl('/api/job'), {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body)}); const d = await r.json(); if (d.ok) { submitted++; } else if (d.error === 'daily_limit' || d.error === 'total_limit') { cnt.textContent = d.error === 'daily_limit' ? 'Tageslimit erreicht (100/100)' : 'Gesamtlimit erreicht'; cnt.style.color = 'var(--orange)'; cnt.classList.add('visible'); loadLimits(); break; } } catch(e) {} } isSubmitting = false; if (submitted > 0) { cnt.textContent = submitted + (submitted === 1 ? ' Job' : ' Jobs') + ' eingereicht'; cnt.style.color = ''; cnt.classList.add('visible'); setTimeout(loadStatus, 600); setTimeout(loadLimits, 1000); } updateDevelopBtn(); } /* ── Retry a failed job: re-submit with the same parameters ── */ async function retryJob(job) { const body = { ratio: job.ratio || 'auto', num_images: job.num_images || 1, resolution: job.resolution || '4k', thinking_level: job.thinking_level || 'high', mode: 'imagen-nano-banana-2-flash', stem: job.stem || 'retry', }; if (job.image) body.image = job.image; if (job.prompt_file) body.prompt_file = job.prompt_file; if (job.prompt) body.prompt = job.prompt; try { const r = await fetch(fApiUrl('/api/job'), {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body)}); const d = await r.json(); if (d.ok) loadStatus(); else alert('Konnte nicht erneut starten: ' + (d.error || 'unbekannt')); } catch(e) { alert('Fehler: ' + e.message); } } /* ── Cancel a pending job ── */ async function cancelJob(jobId) { try { const r = await fetch(fApiUrl('/api/cancel/' + jobId), {method:'POST'}); const d = await r.json(); if (d.ok) { const card = _qMap.get(jobId); if (card) { card.remove(); _qMap.delete(jobId); } loadStatus(); } else { alert('Konnte Job nicht abbrechen: ' + (d.error || 'unbekannt')); } } catch(e) { alert('Fehler beim Abbrechen: ' + e.message); } } /* ── Progress helpers ── */ const PHASE1_DEFAULT_MS = 10000, PHASE1_TARGET_PCT = 10; function progressForJob(job) { if (job._badge === 'done' || job.done) return { pct:100, phase:'done', remaining: null }; if (job._badge === 'failed') return { pct:100, phase:'failed', remaining: null }; const now = Date.now(); if (job.started_at) { const el = now - job.started_at; if (job.eta_ms) return { pct: Math.min(99, PHASE1_TARGET_PCT + Math.max(0, (el/job.eta_ms) * (100-PHASE1_TARGET_PCT))), phase:'active', remaining: Math.max(0, job.eta_ms - el) }; return { pct: Math.min(95, PHASE1_TARGET_PCT + (el/300000)*80), phase:'active', remaining: null }; } if (job.submitted_at) return { pct: Math.max(1, Math.min(PHASE1_TARGET_PCT, (now - job.submitted_at)/PHASE1_DEFAULT_MS * PHASE1_TARGET_PCT)), phase:'phase1', remaining: null }; return { pct:1, phase:'phase1', remaining: null }; } function inputThumbUrl(j) { const p = j.original_path || j.image || null; return p ? _friendsThumbUrl(p) : null; } function thumbUrl(dl) { const p = dl?.previewPath || dl?.outPath; return p ? _friendsThumbUrl(p) : null; } async function openFolder(p) { /* Deaktiviert für Friends-Nutzer — kein lokaler Finder-Zugriff */ } function _friendsThumbUrl(rawPath) { if (!rawPath) return null; return fApiUrl('/api/thumbnail?path=' + encodeURIComponent(rawPath)); } /* ── Fehler-Klassifikation: deckt 13 bekannte Fehlerquellen ab ── * Liefert { label, hint, wait, retry }: * label = kurzer Anzeigetext im Fehler-Pill * hint = optionaler Untertext mit Handlungsempfehlung * wait = true → kein Crash, Worker probiert noch (gelb statt rot) * retry = false → Nochmal-Button macht keinen Sinn (z.B. Datei kaputt) */ function classifyError(msg) { if (!msg) return { label: 'Fehler', retry: true }; const m = msg.toLowerCase(); if (/\bnsfw\b/.test(m)) return { label: 'NSFW', hint: 'Magnific lehnt Bild ab', retry: true }; if (/cloudflare/.test(m)) return { label: 'Cloudflare', hint: 'Magnific in Chrome öffnen + reload', retry: true }; if (/xsrf|nicht.*eingeloggt|kein magnific-tab/.test(m)) return { label: 'Nicht eingeloggt', hint: 'In Magnific einloggen', retry: true }; if (/credit-timeout/.test(m)) return { label: 'Credits leer', hint: '30 min ohne Credits', retry: true }; if (/not enough credits|\b429\b/.test(m)) return { label: 'Credits warten', hint: 'Worker probiert weiter', wait: true, retry: false }; if (/poll-timeout/.test(m)) return { label: 'Timeout', hint: 'Magnific zu langsam', retry: true }; if (/upload \d/.test(m)) return { label: 'Upload-Fehler', hint: msg.split(':')[0], retry: true }; if (/api \d/.test(m)) return { label: 'API-Fehler', hint: msg.split(':')[0], retry: true }; if (/datei nicht stabil/.test(m)) return { label: 'Datei wackelt', hint: 'Quelle wurde noch geschrieben', retry: true }; if (/keine request_tokens|kein identifier/.test(m)) return { label: 'Render-Fehler', hint: 'Magnific-Antwort unklar', retry: true }; if (/preflight/.test(m)) return { label: 'Preflight', hint: 'Magnific antwortet komisch', retry: true }; if (/\bcdp\b/.test(m)) return { label: 'Chrome weg', hint: 'CDP-Port nicht erreichbar', retry: false }; if (/soft_stop|kameramotor_paused/.test(m)) return { label: 'Pausiert', hint: 'Worker auf Pause', wait: true, retry: false }; if (/magnific.*fehlgeschlagen/.test(m)) return { label: 'Magnific-Fehler', hint: 'Grund unbekannt', retry: true }; return { label: 'Fehler', hint: msg.slice(0, 80), retry: true }; } function getFilterLabel(job) { const pf = job.prompt_file; if (!pf) return job.prompt ? 'EIGENER PROMPT' : ''; const fn = pf.split('/').pop() === 'Prompt.txt' ? pf.split('/').at(-2) : pf.split('/').pop().split(' — ')[0]; return FILTER_LABELS[fn] || fn.toUpperCase(); } /* ── Debug timeline (120s wide, colored segments, dots, text) ── * * T = 0 = magnific_submit_at (when we actually hand the image to Magnific). * Fallback if missing: started_at. * Segments: * red = 0 → started_at (transmission/upload to Magnific) * yellow= started_at → title_at (Magnific is processing) * green = title_at → done_at (download phase) * Dots at the boundaries (started_at, title_at, done_at). */ function buildDebugTimeline(el, job) { el.innerHTML = ''; const T0 = job.magnific_submit_at || job.started_at || job.submitted_at || 0; const SCALE = 120000; const bar = document.createElement('div'); bar.className = 'dtl-bar'; function pct(ts) { if (!ts || !T0) return 0; return Math.min(100, Math.max(0, (ts - T0) / SCALE * 100)); } function addSeg(from, to, cls) { if (!from && cls !== 'red') return; const a = pct(from), b = pct(to); if (b <= a) return; const seg = document.createElement('div'); seg.className = 'dtl-seg ' + cls; seg.style.left = a + '%'; seg.style.width = (b - a) + '%'; bar.appendChild(seg); } function addDot(ts, cls) { if (!ts || !T0) return; const d = document.createElement('div'); d.className = 'dtl-dot ' + cls; d.style.left = pct(ts) + '%'; bar.appendChild(d); } /* red: 0 → started_at (only if we know T0 < started_at) */ if (T0 && job.started_at && job.started_at > T0) addSeg(T0, job.started_at, 'red'); /* yellow: started_at → title_at */ if (job.started_at && job.title_at) addSeg(job.started_at, job.title_at, 'yellow'); /* green: title_at → done_at */ if (job.title_at && job.done_at) addSeg(job.title_at, job.done_at, 'green'); /* Dots */ addDot(job.started_at, 'orange'); addDot(job.title_at, 'yellow'); addDot(job.done_at, 'green'); const text = document.createElement('div'); text.className = 'dtl-text'; const parts = []; /* "Hochgeladen Xs" only when we actually know the submit point (T0 < started_at) */ if (job.started_at && T0 && (job.started_at - T0) >= 500) parts.push('Hochgeladen ' + Math.round((job.started_at - T0)/1000) + 's'); if (job.title_at && T0) parts.push('Titel ' + Math.round((job.title_at - T0)/1000) + 's'); if (job.done_at && T0) parts.push('Fertig ' + Math.round((job.done_at - T0)/1000) + 's'); text.textContent = parts.join(' · '); el.append(bar, text); } /* ── Card creation ── */ function createJobCard(job) { const isDone = job._badge === 'done' || job.done; const dlArr = job.downloaded ? (Array.isArray(job.downloaded) ? job.downloaded : Object.values(job.downloaded)) : []; const fl = getFilterLabel(job); if (isDone) { const card = document.createElement('div'); card.className = 'job-card job-done'; card.dataset.jobId = job.id; /* Kein openFolder für Friends-Nutzer (kein lokaler Finder-Zugriff) */ const iu = inputThumbUrl(job); const imgRow = document.createElement('div'); imgRow.className = 'img-row'; if (iu) { const oc = document.createElement('div'); oc.className = 'img-cell original'; const oi = document.createElement('img'); oi.src = iu; oi.alt = 'Original'; oi.addEventListener('click', function(e){ e.stopPropagation(); showFullscreenUrl(iu, null); }); oc.appendChild(oi); imgRow.appendChild(oc); setTimeout(function(){ oc.classList.add('fade-in'); setTimeout(function(){ oc.classList.add('loaded'); }, 400); }, 50); } dlArr.forEach(function(dl, i) { if (!dl || !dl.outPath) return; const cell = document.createElement('div'); cell.className = 'img-cell'; const tu = thumbUrl(dl); const gi = document.createElement('img'); gi.src = tu; gi.alt = ''; gi.addEventListener('click', (function(u, orig){ return function(e){ e.stopPropagation(); showFullscreenUrl(u, orig); }; })(tu, iu)); cell.appendChild(gi); imgRow.appendChild(cell); setTimeout(function(){ cell.classList.add('fade-in'); setTimeout(function(){ cell.classList.add('loaded'); }, 400); }, 80 + i*120); }); card.appendChild(imgRow); const footer = document.createElement('div'); footer.className = 'job-footer visible'; if (job.original_name && job.original_name.title) { const ts = document.createElement('span'); ts.className = 'footer-title'; ts.textContent = job.original_name.title; footer.appendChild(ts); if (fl) { const sp = document.createElement('span'); sp.className = 'footer-sep'; sp.textContent = '·'; footer.appendChild(sp); } } if (fl) { const fs = document.createElement('span'); fs.className = 'footer-filter'; fs.textContent = fl; footer.appendChild(fs); } const tl = document.createElement('div'); tl.className = 'debug-timeline'; buildDebugTimeline(tl, job); /* Footer-Tap: Debug-Modus an (Bilder aus, Timeline an) */ footer.addEventListener('click', function(e) { e.stopPropagation(); card.classList.add('debug-mode'); tl.classList.add('visible'); buildDebugTimeline(tl, job); }); /* Timeline-Tap: Debug-Modus aus (Bilder zurück) */ tl.addEventListener('click', function(e) { e.stopPropagation(); card.classList.remove('debug-mode'); tl.classList.remove('visible'); }); /* ↓ Speichern-Button: öffnet Bild in neuem Tab → iOS zeigt "In Fotos sichern" */ if (dlArr.length > 0) { dlArr.forEach(function(dl) { if (!dl || !dl.outPath) return; const saveBtn = document.createElement('button'); saveBtn.className = 'fotos-btn'; saveBtn.type = 'button'; saveBtn.textContent = '↓ Speichern'; saveBtn.addEventListener('click', function(e) { e.stopPropagation(); /* Browser-native: öffnet in neuem Tab — iOS Safari zeigt "Bild sichern" */ const thumbU = thumbUrl(dl); if (thumbU) window.open(thumbU, '_blank'); }); footer.appendChild(saveBtn); }); } card.appendChild(footer); card.appendChild(tl); return card; } /* Failed card: thumb + filter line + red bang + NSFW/Fehler + Nochmal-Button */ if (job._badge === 'failed') { const card = document.createElement('div'); card.className = 'job-card'; card.dataset.jobId = job.id; const iu = inputThumbUrl(job); const lt = document.createElement('div'); lt.className = 'job-thumb'; if (iu) { const li = document.createElement('img'); li.src = iu; li.alt = ''; lt.addEventListener('click', function(e){ e.stopPropagation(); showFullscreenUrl(iu, null); }); lt.appendChild(li); } const body = document.createElement('div'); body.className = 'job-body'; const meta = document.createElement('div'); meta.className = 'job-meta-under'; meta.appendChild(document.createTextNode([fl, ((job.num_images||2)+'×'), (job.ratio==='auto'?'Auto':job.ratio||'Auto'), ((job.resolution||'4k').toUpperCase())].filter(Boolean).join(' · '))); const cls = classifyError(job.error); const errRow = document.createElement('div'); errRow.className = 'job-error-row'; const exclam = document.createElement('span'); exclam.className = 'job-error-icon' + (cls.wait ? ' wait' : ''); exclam.appendChild(document.createTextNode(cls.wait ? '…' : '!')); const errText = document.createElement('span'); errText.className = 'job-error-text' + (cls.wait ? ' wait' : ''); errText.appendChild(document.createTextNode(cls.label)); errRow.append(exclam, errText); if (cls.retry) { const retryBtn = document.createElement('button'); retryBtn.className = 'job-retry-btn'; retryBtn.type = 'button'; retryBtn.appendChild(document.createTextNode('Nochmal')); retryBtn.addEventListener('click', function(e){ e.stopPropagation(); retryJob(job); }); errRow.appendChild(retryBtn); } body.append(meta, errRow); if (cls.hint) { const hint = document.createElement('div'); hint.className = 'job-error-hint'; hint.appendChild(document.createTextNode(cls.hint)); body.appendChild(hint); } card.append(lt, body); return card; } /* Active/pending card. Pending cards are swipeable (swipe-left to reveal Löschen). */ const isPending = job._badge === 'pending'; const card = document.createElement('div'); card.className = 'job-card' + (isPending ? ' swipeable' : ''); card.dataset.jobId = job.id; const prog = progressForJob(job), iu = inputThumbUrl(job); /* Build inner content (thumb + body) */ function buildContent(into) { const lt = document.createElement('div'); lt.className = 'job-thumb'; if (iu) { const li = document.createElement('img'); li.src = iu; li.alt = ''; li.onerror = function(){ lt.innerHTML = ''; }; lt.addEventListener('click', function(e){ e.stopPropagation(); showFullscreenUrl(iu, null); }); lt.appendChild(li); } const body = document.createElement('div'); body.className = 'job-body'; const pr = document.createElement('div'); pr.className = 'job-progress-row'; const lamp = document.createElement('div'); lamp.className = 'job-lamp' + (prog.phase === 'phase1' ? ' phase1' : prog.phase === 'active' ? ' phase2' : ''); lamp.dataset.lampFor = job.id; const track = document.createElement('div'); track.className = 'progress-track'; const bar = document.createElement('div'); bar.className = 'progress-bar ' + prog.phase; bar.style.width = prog.pct + '%'; bar.dataset.barFor = job.id; track.appendChild(bar); pr.append(lamp, track); const titleEl = document.createElement('div'); titleEl.className = 'job-title-active' + (job.original_name && job.original_name.title ? ' visible' : ''); titleEl.dataset.titleFor = job.id; titleEl.textContent = (job.original_name && job.original_name.title) || ''; const meta = document.createElement('div'); meta.className = 'job-meta-under'; meta.textContent = [fl, ((job.num_images || 2) + '×'), (job.ratio === 'auto' ? 'Auto' : job.ratio || 'Auto'), ((job.resolution || '4k').toUpperCase())].filter(Boolean).join(' · '); body.append(pr, titleEl, meta); into.append(lt, body); } if (isPending) { /* Swipeable: content layer + delete action behind */ const content = document.createElement('div'); content.className = 'swipe-content'; buildContent(content); /* Kein openFolder für Friends-Nutzer */ const delBtn = document.createElement('button'); delBtn.className = 'swipe-delete'; delBtn.type = 'button'; delBtn.textContent = 'Löschen'; delBtn.addEventListener('click', function(e){ e.stopPropagation(); cancelJob(job.id); }); card.append(delBtn, content); attachSwipeToDelete(card, content); } else { buildContent(card); /* Kein openFolder für Friends-Nutzer */ } return card; } /* Swipe handling for pending cards. * Track horizontal drag on content layer. * Drag left > 32px → show delete (reveal 96px). * Snap on release: revealed if dx <= -50, else hidden. * Tap somewhere else → hide. */ function attachSwipeToDelete(card, content) { let sx = 0, sy = 0, lx = 0, dragging = false, decided = false; let isHorizontal = false, baseTx = 0; function close() { content.classList.remove('revealed'); } content.addEventListener('touchstart', function(e) { if (e.touches.length !== 1) return; sx = e.touches[0].clientX; sy = e.touches[0].clientY; lx = sx; dragging = true; decided = false; isHorizontal = false; baseTx = content.classList.contains('revealed') ? -96 : 0; content.classList.add('dragging'); }, {passive:true}); content.addEventListener('touchmove', function(e) { if (!dragging || e.touches.length !== 1) return; const cx = e.touches[0].clientX, cy = e.touches[0].clientY; const dx = cx - sx, dy = cy - sy; if (!decided) { if (Math.abs(dx) > 8 || Math.abs(dy) > 8) { isHorizontal = Math.abs(dx) > Math.abs(dy) * 1.2; decided = true; if (!isHorizontal) { content.classList.remove('dragging'); dragging = false; return; } } else return; } lx = cx; const tx = Math.max(-120, Math.min(0, baseTx + dx)); content.style.transform = 'translateX(' + tx + 'px)'; if (e.cancelable) e.preventDefault(); }, {passive:false}); content.addEventListener('touchend', function() { if (!dragging) return; dragging = false; content.classList.remove('dragging'); content.style.transform = ''; const dx = lx - sx; const wasRevealed = baseTx < 0; if (!isHorizontal) return; if (wasRevealed) { if (dx > 30) close(); else content.classList.add('revealed'); } else { if (dx < -50) content.classList.add('revealed'); else close(); } }, {passive:true}); /* Tap outside closes */ document.addEventListener('click', function(e) { if (!card.isConnected) return; if (!card.contains(e.target)) close(); }); } /* ── loadStatus ── */ const QUEUE_CACHE_KEY = 'friends_queue_v1_' + (FRIENDS_USER.id || 'guest'); function restoreQueueFromCache() { try { const raw = localStorage.getItem(QUEUE_CACHE_KEY); if (!raw) return; const d = JSON.parse(raw); if (d && d.ok) renderQueue(d); } catch {} } async function loadStatus() { try { const r = await fetch(fApiUrl('/api/status')); const d = await r.json(); if (d.ok) { try { localStorage.setItem(QUEUE_CACHE_KEY, JSON.stringify(d)); } catch {} renderQueue(d); } } catch(e) { // Cache bleibt sichtbar; nur wenn auch kein Cache da, Fehler zeigen if (!localStorage.getItem(QUEUE_CACHE_KEY)) { document.getElementById('queue-content').innerHTML = '
Verbindung unterbrochen.
'; } } } let lastServerData = null; function _ensureQueueList() { const content = document.getElementById('queue-content'); if (!_qList || !_qList.isConnected) { content.innerHTML = ''; _qList = document.createElement('div'); _qList.className = 'queue-list'; content.appendChild(_qList); _qMap.clear(); } } function renderQueue(d) { lastServerData = d; const active = (d.active || []).map(function(j){ return Object.assign({}, j, {_badge:'active'}); }); const pending = (d.pending || []).map(function(j){ return Object.assign({}, j, {_badge:'pending'}); }); const doneAll = [...(d.done || [])].reverse().map(function(j){ return Object.assign({}, j, {_badge:'done'}); }); const failAll = [...(d.failed || [])].reverse().map(function(j){ return Object.assign({}, j, {_badge:'failed'}); }); const ap = [...active, ...pending]; const doneAndFailed = [...doneAll, ...failAll]; if (!ap.length && !doneAndFailed.length) { document.getElementById('queue-content').innerHTML = '
Keine Jobs — Motor wartet.
'; _qMap.clear(); _qList = null; return; } buildLibrary(doneAndFailed); _ensureQueueList(); const currentIds = new Set([...ap, ...doneAndFailed.slice(0, _qDonePage*7)].map(function(j){ return j.id; })); _qMap.forEach(function(el, id) { if (!currentIds.has(id)) { el.remove(); _qMap.delete(id); } }); ap.forEach(function(job, i) { let card = _qMap.get(job.id); if (!card) { card = createJobCard(job); _qMap.set(job.id, card); } else if (card.classList.contains('job-done')) { const nc = createJobCard(job); _qList.replaceChild(nc, card); _qMap.set(job.id, nc); card = nc; } const atPos = _qList.children[i]; if (atPos !== card) _qList.insertBefore(card, atPos || null); }); const visibleDone = doneAndFailed.slice(0, _qDonePage*7); const apCount = ap.length; visibleDone.forEach(function(job, i) { let card = _qMap.get(job.id); if (!card) { card = createJobCard(job); _qMap.set(job.id, card); _qList.appendChild(card); } else if (!card.classList.contains('job-done')) { const nc = createJobCard(job); _qList.replaceChild(nc, card); _qMap.set(job.id, nc); card = nc; } const targetIdx = apCount + i; const atPos = _qList.children[targetIdx]; if (atPos !== card) _qList.insertBefore(card, atPos || null); }); _qList.querySelectorAll('.load-more-row').forEach(function(el){ el.remove(); }); const total = doneAndFailed.length; if (total > _qDonePage*7) { const row = document.createElement('div'); row.className = 'load-more-row'; const btn = document.createElement('button'); btn.className = 'load-more-btn'; const remaining = total - _qDonePage*7; btn.textContent = '+ ' + Math.min(7, remaining) + ' ältere'; btn.addEventListener('click', function(){ _qDonePage++; renderQueue(lastServerData); }); row.appendChild(btn); _qList.appendChild(row); } } /* ── Variation modal ── */ let variationContext = null; function openVariation(job, idx) { const seed = job.seeds?.[idx], dl = job.downloaded?.[idx]; if (seed == null || !dl?.outPath) return; variationContext = {job, idx, seed}; document.getElementById('variation-seed').replaceChildren(String(seed)); document.getElementById('variation-preview').src = thumbUrl(dl) || ''; let prompt = job.prompt || ''; if (!prompt && job.prompt_file) prompt = '[Filter: ' + job.prompt_file.split('/').pop().split(' — ')[0] + ']\n\n'; document.getElementById('variation-prompt').value = prompt; document.getElementById('variation-modal').classList.add('visible'); setTimeout(function(){ document.getElementById('variation-prompt')?.focus(); }, 50); } function closeVariation() { document.getElementById('variation-modal').classList.remove('visible'); variationContext = null; } async function submitVariation() { if (!variationContext) return; const {job, seed} = variationContext; const newPrompt = document.getElementById('variation-prompt').value.trim(); if (!newPrompt) { alert('Bitte Prompt eingeben.'); return; } const body = { ratio: job.ratio || 'auto', num_images: 1, resolution: job.resolution || '4k', thinking_level: job.thinking_level || 'high', mode: 'imagen-nano-banana-2-flash', stem: (job.stem || 'Variation') + ' - Seed ' + seed, prompt: newPrompt, seeds: [seed] }; if (job.image) body.image = job.image; try { const r = await fetch(fApiUrl('/api/job'), {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body)}); const d = await r.json(); if (d.ok) { closeVariation(); loadStatus(); } else alert('Fehler: ' + (d.error || 'unbekannt')); } catch(e) { alert('Fehler: ' + e.message); } } /* ── Live ticking of active cards ── */ function tickActiveBars() { if (!lastServerData) return; function tick(j) { const card = _qMap.get(j.id); if (!card) return; const prog = progressForJob(j); const bar = card.querySelector('[data-bar-for="' + j.id + '"]'); if (bar) { bar.style.width = prog.pct + '%'; bar.className = 'progress-bar ' + prog.phase; } const lamp = card.querySelector('[data-lamp-for="' + j.id + '"]'); if (lamp) { if (prog.phase === 'phase1') lamp.className = 'job-lamp phase1'; else if (prog.phase === 'active') lamp.className = 'job-lamp phase2'; } const te = card.querySelector('[data-title-for="' + j.id + '"]'); if (te && j.original_name && j.original_name.title && !te.classList.contains('visible')) { te.textContent = j.original_name.title; te.classList.add('visible'); } } (lastServerData.active || []).forEach(tick); (lastServerData.pending || []).forEach(tick); } let pollTimer = null; function schedulePoll() { clearTimeout(pollTimer); const n = (lastServerData?.active?.length || 0) + (lastServerData?.pending?.length || 0); pollTimer = setTimeout(async function(){ await loadStatus(); schedulePoll(); }, n > 0 ? 1500 : 5000); } Fullscreen.init(); /* ── Eigene Kamera (getUserMedia, 16:9, EXIF-frei dank Canvas-Capture) ── * Liefert ein File-Objekt zurück, das im selben Pfad wie der iOS-Picker landet. * Kein 90°-Bug, weil Canvas direkt vom Video-Frame zieht (keine EXIF involviert). */ const Cam = (function() { let overlay, video, viewfinder, flashEl, shutterBtn, statusEl; let stream = null, facing = 'environment', resolver = null; function init() { overlay = document.getElementById('cc-overlay'); video = document.getElementById('cc-video'); viewfinder = document.getElementById('cc-viewfinder'); flashEl = document.getElementById('cc-flash'); shutterBtn = document.getElementById('cc-shutter'); statusEl = document.getElementById('cc-status'); document.getElementById('cc-close-btn').addEventListener('click', function(){ Cam.close(null); }); document.getElementById('cc-flip-btn').addEventListener('click', function(){ Cam.flip(); }); shutterBtn.addEventListener('click', function(){ Cam.capture(); }); } async function startStream() { stopStream(); statusEl.firstChild ? (statusEl.firstChild.nodeValue = 'Kamera startet…') : statusEl.appendChild(document.createTextNode('Kamera startet…')); try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facing, width: { ideal: 3840 }, height: { ideal: 2160 } }, audio: false }); video.srcObject = stream; video.classList.toggle('mirror', facing === 'user'); statusEl.firstChild.nodeValue = 'Sucher aktiv'; } catch (e) { statusEl.firstChild.nodeValue = 'Fehler: ' + (e.name || 'unbekannt'); } } function stopStream() { if (stream) { stream.getTracks().forEach(function(t){ t.stop(); }); stream = null; } video.srcObject = null; } async function open() { return new Promise(function(resolve) { resolver = resolve; overlay.classList.add('visible'); startStream(); }); } function close(result) { stopStream(); overlay.classList.remove('visible'); const r = resolver; resolver = null; if (r) r(result || null); } function flip() { facing = (facing === 'environment') ? 'user' : 'environment'; startStream(); } function capture() { if (!stream || shutterBtn.classList.contains('busy')) return; shutterBtn.classList.add('busy'); flashEl.classList.add('active'); setTimeout(function(){ flashEl.classList.remove('active'); }, 160); const vw = video.videoWidth, vh = video.videoHeight; const target = 16/9; let cw, ch, ox, oy; if (vw / vh > target) { ch = vh; cw = Math.round(vh * target); ox = Math.round((vw - cw)/2); oy = 0; } else { cw = vw; ch = Math.round(vw / target); ox = 0; oy = Math.round((vh - ch)/2); } const canvas = document.createElement('canvas'); canvas.width = cw; canvas.height = ch; const ctx = canvas.getContext('2d'); if (facing === 'user') { ctx.translate(cw, 0); ctx.scale(-1, 1); } ctx.drawImage(video, ox, oy, cw, ch, 0, 0, cw, ch); canvas.toBlob(function(blob) { shutterBtn.classList.remove('busy'); if (!blob) { close(null); return; } const file = new File([blob], 'aufnahme_' + Date.now() + '.jpg', { type: 'image/jpeg' }); close(file); }, 'image/jpeg', 0.92); } return { init: init, open: open, close: close, flip: flip, capture: capture }; })(); Cam.init(); async function openOwnCamera() { const file = await Cam.open(); if (!file) return; /* Same path as iOS picker: set pendingFile + preview */ pendingFile = file; const url = URL.createObjectURL(file); document.getElementById('preview-img').src = url; document.getElementById('preview-frame').classList.add('visible'); updateDevelopBtn(); scheduleSettingsSave(); } restoreQueueFromCache(); loadFilters(); loadSettings(); loadStatus().then(schedulePoll); /* ── Update-Badge ── */ (function initUpdateBadge() { var knownVersion = null; function checkVersion() { fetch('/api/page_version?page=friends_thecamera.html&_=' + Date.now()) .then(function(r){ return r.json(); }) .then(function(d) { if (!d.version) return; if (knownVersion === null) { knownVersion = d.version; return; } if (d.version !== knownVersion) { document.getElementById('update-badge').style.display = 'block'; } }).catch(function(){}); } checkVersion(); setInterval(checkVersion, 20000); })(); /* Kein SSE vom Friends-Server — Polling reicht */ setInterval(tickActiveBars, 500); setInterval(function(){ loadFilters(true); }, 20000); /* ── App-Titel + Wordmark via FRIENDS_USER setzen ── */ (function applyFriendsIdentity() { const appName = FRIENDS_USER.app_name || 'Meine Kamera'; document.title = appName; const wm = document.getElementById('app-wordmark'); if (wm) wm.textContent = appName; /* apple-mobile-web-app-title */ const metaTitle = document.querySelector('meta[name="apple-mobile-web-app-title"]'); if (metaTitle) metaTitle.setAttribute('content', appName); })(); /* ── Limits laden und anzeigen ── */ async function loadLimits() { try { const r = await fetch(fApiUrl('/api/limits')); const d = await r.json(); if (d.ok) { const el = document.getElementById('limit-info'); if (el) el.textContent = d.daily_used + ' / ' + d.daily_limit + ' heute'; } } catch(e) {} } loadLimits();