diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 33ef8b6..6d2e4a3 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -10017,7 +10017,7 @@ body.modal-open { .modal-actions { display: flex; - justify-content: flex-end; + justify-content: center; gap: 10px; margin-top: 15px; } diff --git a/public/s/css/upload.css b/public/s/css/upload.css index fd89eb4..21a3175 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -219,7 +219,6 @@ .upload-form:not(.shitpost-mode-active) .preview-media-small { width: 100%; - max-width: 400px; min-height: auto; height: auto; aspect-ratio: 16 / 9; @@ -257,6 +256,7 @@ flex-direction: column; overflow: hidden; min-width: 0; + width: 100%; } .file-info-small { @@ -442,7 +442,7 @@ .preview-media-small { flex: 0 0 50%; - width: 50% !important; + width: 100% !important; height: auto !important; min-height: 120px; max-height: 350px; @@ -1345,3 +1345,138 @@ 50% { opacity: 0.8; } 100% { opacity: 0.4; } } + +/* ── Ruffle (Flash) Upload Preview ─────────────────────────────────────────── */ + +/* The container: CSS Grid with 3 rows — player | snapshot preview | button. + All rows are auto so the container grows naturally; no overflow clipping. */ +.swf-upload-preview { + position: relative; + display: grid; + grid-template-rows: auto auto auto; + max-height: none !important; + background: #000; + border-radius: 8px; +} + +/* Normal (non-shitpost) mode: full-width, player drives container height via aspect-ratio */ +.upload-form:not(.shitpost-mode-active) .swf-upload-preview.preview-media-small { + width: 100% !important; + height: auto !important; + max-height: none !important; + min-height: auto !important; +} + +.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-player, +.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-object { + aspect-ratio: 16 / 9; + width: 100% !important; + height: auto !important; +} + +/* Shitpost mode: player row is a fixed height so the snapshot row doesn't steal its space */ +.upload-form.shitpost-mode-active .swf-upload-preview.preview-media-small { + width: 45% !important; + flex: 0 0 45% !important; + height: auto !important; + max-height: none !important; + min-height: auto !important; + aspect-ratio: unset; +} + +.upload-form.shitpost-mode-active .swf-upload-preview ruffle-player, +.upload-form.shitpost-mode-active .swf-upload-preview ruffle-object { + width: 100% !important; + height: 220px !important; + min-height: 220px; +} + +/* Placeholder shown while Ruffle is loading */ +.swf-upload-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + min-height: 200px; + background: rgba(0, 0, 0, 0.6); + animation: thumbPulse 1.8s ease-in-out infinite; +} + +.swf-upload-placeholder-icon { + font-size: 2.5rem; + line-height: 1; + filter: drop-shadow(0 0 8px rgba(255, 200, 0, 0.7)); +} + +.swf-upload-placeholder-text { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.5); + letter-spacing: 0.5px; +} + +/* ── Ruffle Snapshot Button ─────────────────────────────────────────────────── */ + +/* The snapshot button lives inside .swf-upload-preview, pinned to the bottom */ +.swf-upload-preview { + position: relative; + display: flex; + flex-direction: column; +} + +.btn-ruffle-snapshot { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + margin-top: 0; + padding: 8px 16px; + background: rgba(255, 200, 0, 0.08); + border: none; + border-top: 1px solid rgba(255, 200, 0, 0.2); + border-radius: 0 0 6px 6px; + color: rgba(255, 200, 0, 0.9); + font-size: 0.85rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: all 0.2s ease; + letter-spacing: 0.3px; + flex-shrink: 0; +} + +.btn-ruffle-snapshot:hover:not(:disabled) { + background: rgba(255, 200, 0, 0.15); + border-top-color: rgba(255, 200, 0, 0.4); + color: #ffd700; +} + +.btn-ruffle-snapshot:active:not(:disabled) { + background: rgba(255, 200, 0, 0.22); +} + +.btn-ruffle-snapshot:disabled { + opacity: 0.6; + cursor: not-allowed; +} + + +/* Snapshot preview: sits in its own grid row, below the player */ +.swf-upload-preview .ruffle-snapshot-preview { + display: block; + width: 100%; + height: auto; + object-fit: contain; + object-position: center; + background: rgba(0, 0, 0, 0.6); + border-top: 1px solid rgba(81, 207, 102, 0.3); + animation: snapPreviewIn 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +@keyframes snapPreviewIn { + from { opacity: 0; transform: scale(0.96) translateY(4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 4ad5953..50715b9 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -1658,7 +1658,8 @@ window.cancelAnimFrame = (function () { "unmuteOverlay": "hidden", "letterbox": "on", "warnOnUnsupportedContent": false, - "contextMenu": "on" + "contextMenu": "on", + "splashScreen": false }; let ruffleKeepAliveApplied = false; @@ -1743,15 +1744,46 @@ window.cancelAnimFrame = (function () { applyRuffleKeepAlive(); + // Detect whether the item is currently behind a blur overlay. + // If so, load the SWF with autoplay:"off" so it stays paused until the user reveals it. + const mediaObj = container.closest('.media-object'); + let blurActive = false; + if (mediaObj && !mediaObj.classList.contains('revealed') && localStorage.getItem('blurDetail') !== 'false') { + const blurNsfw = localStorage.getItem('blurNsfw') === 'true'; + const blurNsfl = localStorage.getItem('blurNsfl') === 'true'; + const blurSfw = localStorage.getItem('blurSfw') === 'true'; + const blurUntagged = localStorage.getItem('blurUntagged') === 'true'; + const mode = mediaObj.getAttribute('data-mode'); + blurActive = + (mode === 'nsfw' && blurNsfw) || + (mode === 'nsfl' && blurNsfl) || + (mode === 'sfw' && blurSfw) || + (mode === 'untagged' && blurUntagged); + } + // Update config with latest session preferences just before creation if (window.RufflePlayer) { window.RufflePlayer.config = window.RuffleConfig = { ...window.RuffleConfig, + "autoplay": blurActive ? "off" : "on", "pageVisibility": window.f0ckSession?.ruffle_background === false, - "backgroundExecution": window.f0ckSession?.ruffle_background === false ? undefined : "Unthrottled" + "backgroundExecution": window.f0ckSession?.ruffle_background === false ? undefined : "Unthrottled", + "splashScreen": false }; } + // Patch WebGL getContext to force preserveDrawingBuffer:true so canvas + // frames can be captured after Ruffle renders (WebGL clears buffers by default) + const _origGetCtx = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function(type, attrs) { + if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') { + attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true }); + } + return _origGetCtx.call(this, type, attrs); + }; + // Restore after Ruffle has created its context + setTimeout(() => { HTMLCanvasElement.prototype.getContext = _origGetCtx; }, 8000); + function createPlayer() { if (!window.RufflePlayer) return; if (typeof window.RufflePlayer.newest !== 'function') return; // WASM still initializing @@ -3683,7 +3715,158 @@ window.cancelAnimFrame = (function () { e.preventDefault(); const reBtn = target.closest('#a_rethumb'); const itemId = reBtn.dataset.itemId; - + + // Helper: POST a blob as thumbnail + const submitRethumb = (blob, currentItemId) => { + const fd = new FormData(); + fd.append('file', blob, 'thumbnail.jpg'); + window.flashMessage(i18n.uploading || 'Uploading...'); + fetch(`/api/v2/items/${currentItemId}/thumbnail`, { + method: 'POST', + headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }, + body: fd + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + window.flashMessage(data.msg); + if (window.refreshItemThumbnails) window.refreshItemThumbnails(currentItemId); + } else { + window.flashError(data.msg || 'Upload failed'); + } + }) + .catch(err => { window.flashError('Upload error'); console.error(err); }); + }; + + // For Flash items: show a capture modal instead of a file picker + const rufflePlayer = document.querySelector('#ruffle-container ruffle-player'); + if (rufflePlayer) { + + const tryFindCanvas = (root) => { + if (!root) return null; + const c = root.querySelector('canvas'); + if (c) return c; + for (const el of root.querySelectorAll('*')) { + if (el.shadowRoot) { const found = tryFindCanvas(el.shadowRoot); if (found) return found; } + } + return null; + }; + + // POST blob to thumbnail API and close modal + const submitRethumb = (blob) => { + const fd = new FormData(); + fd.append('file', blob, 'thumbnail.jpg'); + window.flashMessage(i18n.uploading || 'Uploading...'); + fetch(`/api/v2/items/${itemId}/thumbnail`, { + method: 'POST', + headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }, + body: fd + }) + .then(r => r.json()) + .then(data => { + if (data.success) { + window.flashMessage(data.msg); + if (window.refreshItemThumbnails) window.refreshItemThumbnails(itemId); + } else { + window.flashError(data.msg || 'Upload failed'); + } + }) + .catch(err => { window.flashError('Upload error'); console.error(err); }); + }; + + // Capture current Ruffle frame → return blob via callback + const captureFrame = (cb) => { + const canvas = tryFindCanvas(rufflePlayer.shadowRoot || rufflePlayer) || tryFindCanvas(rufflePlayer); + if (!canvas || canvas.width === 0 || canvas.height === 0) { + cb(null); + return; + } + const out = document.createElement('canvas'); + const MAX = 640; + const w = canvas.width, h = canvas.height; + if (w > MAX || h > MAX) { + const ratio = Math.min(MAX / w, MAX / h); + out.width = Math.round(w * ratio); + out.height = Math.round(h * ratio); + } else { + out.width = w || 320; + out.height = h || 240; + } + out.getContext('2d').drawImage(canvas, 0, 0, out.width, out.height); + out.toBlob(cb, 'image/jpeg', 0.92); + }; + + // Build & show modal using site CSS classes + const showCaptureModal = (initialBlob) => { + document.getElementById('rethumb-capture-modal')?.remove(); + + let currentBlob = initialBlob; + + const overlay = document.createElement('div'); + overlay.id = 'rethumb-capture-modal'; + overlay.className = 'modal-overlay'; + overlay.style.zIndex = '99999'; + + const box = document.createElement('div'); + box.className = 'modal-content'; + box.style.cssText = 'max-width:480px;width:90%;text-align:left;position:relative;'; + + const title = document.createElement('h3'); + title.textContent = 'Thumbnail Preview'; + title.style.marginTop = '0'; + + const img = document.createElement('img'); + img.src = URL.createObjectURL(initialBlob); + img.style.cssText = 'width:100%;height:auto;display:block;border-radius:0;margin-bottom:4px;'; + + const actions = document.createElement('div'); + actions.className = 'modal-actions'; + + const btnUse = document.createElement('button'); + btnUse.textContent = 'Use as Thumbnail'; + btnUse.className = 'btn-danger'; + + const btnRecapture = document.createElement('button'); + btnRecapture.textContent = 'Capture Again'; + btnRecapture.className = 'btn-secondary'; + + const btnCancel = document.createElement('button'); + btnCancel.textContent = 'Cancel'; + btnCancel.className = 'btn-secondary'; + + const close = () => overlay.remove(); + + btnUse.onclick = () => { submitRethumb(currentBlob); close(); }; + btnRecapture.onclick = () => { + captureFrame((blob) => { + if (!blob) { window.flashError('Ruffle not ready'); return; } + URL.revokeObjectURL(img.src); + currentBlob = blob; + img.src = URL.createObjectURL(blob); + }); + }; + btnCancel.onclick = close; + overlay.onclick = (ev) => { if (ev.target === overlay) close(); }; + + actions.append(btnUse, btnRecapture, btnCancel); + box.append(title, img, actions); + overlay.appendChild(box); + document.body.appendChild(overlay); + }; + + captureFrame((blob) => { + if (blob) { + showCaptureModal(blob); + } else { + window.flashError('Ruffle not ready — play the Flash first'); + } + }); + + return; // never open file picker for Flash items + } + + + // Non-Flash items: use the file picker as before let fileInput = document.getElementById('rethumb-file-input'); if (!fileInput) { fileInput = document.createElement('input'); @@ -3692,52 +3875,24 @@ window.cancelAnimFrame = (function () { fileInput.accept = 'image/png, image/jpeg, image/gif, image/webp'; fileInput.style.display = 'none'; document.body.appendChild(fileInput); - + fileInput.addEventListener('change', function(evt) { const file = evt.target.files[0]; if (!file) return; - if (file.size > 5 * 1024 * 1024) { window.flashError('File is too large (max 5MB)'); fileInput.value = ''; return; } - const currentItemId = fileInput.dataset.itemId; - const fd = new FormData(); - fd.append('file', file); - - window.flashMessage(i18n.uploading || 'Uploading...'); - - fetch(`/api/v2/items/${currentItemId}/thumbnail`, { - method: 'POST', - headers: { - 'X-CSRF-Token': window.f0ckSession?.csrf_token - }, - body: fd - }) - .then(r => r.json()) - .then(data => { - fileInput.value = ''; - if (data.success) { - window.flashMessage(data.msg); - if (window.refreshItemThumbnails) { - window.refreshItemThumbnails(currentItemId); - } - } else { - window.flashError(data.msg || 'Upload failed'); - } - }) - .catch(err => { - fileInput.value = ''; - window.flashError('Upload error'); - console.error(err); - }); + submitRethumb(file, currentItemId); + fileInput.value = ''; }); } - + fileInput.dataset.itemId = itemId; fileInput.click(); + } else if (target.closest('button#a_toggle')) { e.preventDefault(); const toggleBtn = target.closest('button#a_toggle'); @@ -9849,12 +10004,18 @@ document.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); mediaObj.classList.add('revealed'); - + // Start audio/video playback cleanly on reveal const videoElem = mediaObj.querySelector('video') || mediaObj.querySelector('audio'); if (videoElem) { videoElem.play().catch(() => {}); } + + // Start Ruffle playback if this is a Flash item — it was loaded with autoplay:"off" + const rufflePlayer = mediaObj.querySelector('ruffle-player'); + if (rufflePlayer) { + try { rufflePlayer.play(); } catch(err) {} + } return; } } diff --git a/public/s/js/upload.js b/public/s/js/upload.js index 74dbdc2..f35561e 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -782,6 +782,7 @@ window.initUploadForm = (selector) => { // Basic validation (MIME/Extension/Size) const container = form.closest('.upload-container'); const mimesSource = form.getAttribute('data-mimes') || (container ? container.getAttribute('data-mimes') : null); + const swfEnabled = form.getAttribute('data-enable-swf') !== '0'; let allowedMimes = []; let allowedExts = []; try { @@ -793,6 +794,19 @@ window.initUploadForm = (selector) => { } const fileExt = file.name.split('.').pop().toLowerCase(); + const isSwfFile = fileExt === 'swf' || + file.type === 'application/x-shockwave-flash' || + file.type === 'application/vnd.adobe.flash.movie'; + + // Reject SWF when Flash uploads are disabled + if (isSwfFile && !swfEnabled) { + const errorMsg = 'Flash (.swf) uploads are disabled.'; + if (typeof window.flashMessage === 'function') window.flashMessage('✕ ' + errorMsg, 4000, 'error'); + else if (window.showFlash) window.showFlash(errorMsg, 'error'); + else if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.className = 'upload-status error'; } + continue; + } + const mimeOk = !file.type || allowedMimes.includes(file.type); const extOk = allowedExts.length > 0 && allowedExts.includes(fileExt); @@ -952,8 +966,147 @@ window.initUploadForm = (selector) => { mediaElem.style.width = '100%'; } else if (file.type === 'application/x-shockwave-flash' || file.type === 'application/vnd.adobe.flash.movie' || fileExt === 'swf') { mediaElem = document.createElement('div'); - mediaElem.className = 'generic-file-icon swf-preview-icon'; - mediaElem.innerHTML = '⚡'; + mediaElem.className = 'swf-upload-preview'; + mediaElem.dataset.swfFile = 'pending'; + // Placeholder shown while Ruffle loads + mediaElem.innerHTML = `