From a7c3a5e4722c6612d36363ba794ff9c8eb4e65fd Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Wed, 16 Sep 2026 05:30:51 +0200 Subject: [PATCH] hgfd --- public/s/css/f0ckm.css | 101 ++- public/s/js/admin.js | 12 +- public/s/js/f0ckm.js | 1224 +++++++++++++++++++++++++++++-- public/s/js/settings.js | 30 +- public/s/js/upload.js | 12 +- public/s/js/user.js | 22 +- src/inc/routes/apiv2/tags.mjs | 20 + src/inc/routes/apiv2/upload.mjs | 8 +- src/inc/routes/external.mjs | 4 +- src/inc/routes/mod.mjs | 11 +- src/upload_handler.mjs | 8 +- views/settings.html | 94 +-- 12 files changed, 1374 insertions(+), 172 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 6cdce17..2b72ec5 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -42,6 +42,8 @@ --x2154: #215483; --bg-canvas-opacity: 1.0; --bg-canvas-blur: 30px; + --bg-canvas-color: transparent; + --bg-canvas-color-opacity: 0.0; --onara-bg-opacity: 0.7; --onara-backdrop-blur: 5px; --onara-grid-dim-opacity: 0.38; @@ -3270,6 +3272,27 @@ body.sidebar-right-hidden #sidebar-drag-zone { will-change: transform, box-shadow, border-color; } +/* Motion trail ghost — faint blurred echo of the eye that smears on fast movement */ +.eye-trail-ghost { + position: absolute; + top: 50%; + left: 50%; + width: clamp(300px, 72vmin, 700px); + height: clamp(300px, 72vmin, 700px); + max-width: calc(100% - 60px); + max-height: calc(100% - 80px); + border-radius: 50%; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-color: transparent; + pointer-events: none; + z-index: 0; + opacity: 0; + will-change: transform, opacity, filter; + mix-blend-mode: screen; +} + .audio-eye-inner-canvas { position: absolute; top: 50%; @@ -3280,6 +3303,16 @@ body.sidebar-right-hidden #sidebar-drag-zone { z-index: 2; } +.audio-void-outer-canvas { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 0; + border-radius: 0; +} + .audio-cover-circle i { font-size: 3.4rem; color: var(--accent); @@ -3365,10 +3398,10 @@ body.sidebar-right-hidden #sidebar-drag-zone { .v0ck.v0ck_fullscreen .audio-cover-circle, #main:fullscreen .v0ck.v0ck_fullscreen .audio-cover-circle, #onara-modal:fullscreen .v0ck.v0ck_fullscreen .audio-cover-circle { - width: clamp(260px, 56vmin, 560px); - height: clamp(260px, 56vmin, 560px); - max-width: calc(100% - 80px); - max-height: calc(100% - 100px); + width: clamp(300px, 72vmin, 700px); + height: clamp(300px, 72vmin, 700px); + max-width: calc(100% - 60px); + max-height: calc(100% - 80px); } .v0ck:fullscreen .audio-cover-circle i, @@ -10365,6 +10398,7 @@ div.favs div.posts { opacity: var(--bg-canvas-opacity, 1.0); filter: var(--bg-canvas-filter, none); -webkit-filter: var(--bg-canvas-filter, none); + background-color: var(--bg-canvas-color, transparent); } button#togglebg { @@ -20816,6 +20850,24 @@ body.onara-modal-open .posts > a.thumb.onara-active::after { opacity: 1 !important; } +body.onara-modal-open .posts > a.thumb.loaded { + opacity: 1 !important; + filter: none !important; + z-index: 50 !important; + position: relative !important; +} + +body.onara-modal-open #bg, +body.onara-modal-open #bg.fader-in, +body.onara-modal-open #bg.fader-out, +body.onara-modal-open #bg.fast-fade { + z-index: 51 !important; + cursor: pointer !important; + transition: none !important; + animation: none !important; + opacity: var(--bg-canvas-opacity, 1.0) !important; +} + /* Navbar and right sidebar stay fully visible, sharp, and interactive */ body.onara-modal-open nav.navbar { opacity: 1 !important; @@ -20885,7 +20937,7 @@ body.onara-modal-open .admin-bar.open { transition: opacity 0.28s cubic-bezier(0.16, 1, 0.3, 1), transform 0.28s cubic-bezier(0.16, 1, 0.3, 1), right 0.3s ease-in-out !important; - cursor: default; + cursor: pointer; } .onara-modal-wrapper::-webkit-scrollbar { @@ -20962,7 +21014,7 @@ body.sidebar-right-hidden .onara-modal-wrapper { height: 100% !important; min-height: 100% !important; z-index: 1 !important; - cursor: default; + cursor: pointer; } .onara-modal-content { @@ -20977,7 +21029,7 @@ body.sidebar-right-hidden .onara-modal-wrapper { flex: 1 0 auto !important; padding: 20px 0 80px 0 !important; box-sizing: border-box !important; - cursor: default; + cursor: pointer; } #onara-item-mount { @@ -20986,7 +21038,7 @@ body.sidebar-right-hidden .onara-modal-wrapper { flex-direction: column !important; align-items: center !important; justify-content: flex-start !important; - cursor: default; + cursor: pointer; } /* Center the item layout inside modal and make it 100% scrollable */ @@ -21008,13 +21060,13 @@ body.layout-modern #onara-modal .item-layout-container { box-sizing: border-box !important; grid-template-columns: none !important; grid-template-rows: none !important; - cursor: default; + cursor: pointer; } #onara-modal .item-main-content, body.layout-legacy #onara-modal .item-layout-container .item-main-content, body.layout-modern #onara-modal .item-layout-container .item-main-content { - cursor: default !important; + cursor: pointer !important; display: flex !important; flex-direction: column !important; align-items: center !important; @@ -21035,6 +21087,35 @@ body.layout-modern #onara-modal .item-layout-container .item-main-content { display: none !important; } +#onara-modal .item-main-content > *, +#onara-modal .content, +#onara-modal ._204863, +#onara-modal .item_title, +#onara-modal .item-title, +#onara-modal .metadata, +#onara-modal #comments-container, +#onara-modal .item-sidebar-left, +#onara-modal .media-object, +#onara-modal .v0ck { + cursor: default; +} + +#onara-modal a, +#onara-modal button, +#onara-modal .btn, +#onara-modal [role="button"], +#onara-modal label, +#onara-modal .rating-tag, +#onara-modal .sidebar-tag { + cursor: pointer; +} + +#onara-modal input, +#onara-modal textarea, +#onara-modal [contenteditable="true"] { + cursor: text; +} + #onara-modal .item-main-content > * { width: 100% !important; max-width: 800px !important; diff --git a/public/s/js/admin.js b/public/s/js/admin.js index 05d8d30..618d5c2 100644 --- a/public/s/js/admin.js +++ b/public/s/js/admin.js @@ -107,8 +107,16 @@ if (canManage) { span.classList.add('can-cycle'); } - } else if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) { - span.setAttribute("tooltip", tag.display_name || tag.user); + } else { + span.classList.add('tag-badge'); + span.setAttribute('data-tag-id', tag.id || ''); + span.setAttribute('data-tag', tag.tag); + span.setAttribute('data-tag-normalized', tag.normalized); + if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) { + span.setAttribute("tooltip", tag.display_name || tag.user); + } + const isExcl = !!tag.is_excluded; + if (isExcl) span.classList.add('tag-is-excluded'); } span.appendChild(contentEl); diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index a75404f..bf10e6f 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -1650,7 +1650,11 @@ window.cancelAnimFrame = (function () { }; // ── Onara Alternative Flavor: Fullscreen Modal Wrapper ───────────────────── - const isOnaraActive = () => !!(window.f0ckSession?.onara || window.onara); + const isOnaraActive = () => { + const stored = (() => { try { return localStorage.getItem('f0ck_onara_enabled'); } catch { return null; } })(); + if (stored !== null) return stored === 'true'; + return !!(window.f0ckSession?.onara || window.onara); + }; const getOrCreateOnaraModal = () => { let modal = document.getElementById('onara-modal'); @@ -1687,6 +1691,19 @@ window.cancelAnimFrame = (function () { modal.classList.remove('onara-fade-out'); void modal.offsetWidth; // Force reflow so transition starts reliably modal.classList.add('onara-fade-in'); + + const canvas = document.getElementById('bg'); + if (canvas) { + canvas._bgFadingOut = false; + canvas.classList.remove('fader-out', 'fast-fade'); + canvas.classList.add('fader-in'); + canvas.style.transition = 'none'; + canvas.style.opacity = 'var(--bg-canvas-opacity, 1.0)'; + } + if (typeof window.paintImmediateBgThumb === 'function') { + window.paintImmediateBgThumb(); + } + return modal; }; window.openOnaraModal = openOnaraModal; @@ -2108,7 +2125,7 @@ window.cancelAnimFrame = (function () { if (e.target.closest(_onaraContentSelector)) return; // Any click on free space (backdrop, modal wrapper, container background, empty space in item-main-content) closes - if (e.target.closest('#onara-modal') || e.target.id === 'onara-backdrop') { + if (e.target.closest('#onara-modal') || e.target.id === 'onara-backdrop' || e.target.id === 'bg') { e.preventDefault(); closeOnaraModal(); } @@ -3188,6 +3205,35 @@ window.cancelAnimFrame = (function () { preload(currentIndex - 1); }; + // Helper that returns the parent album's rating as a minimal tag object array. + // The authoritative value is stored in #tags[data-parent-rating] and initialised + // from the server-rendered DOM on first load, then kept in sync by handleTagsUpdate. + const getParentRatingTags = () => { + const tagsContainer = document.querySelector('#tags'); + if (!tagsContainer) return []; + const r = tagsContainer.dataset.parentRating || ''; + if (!['sfw', 'nsfw', 'nsfl'].includes(r)) return []; + const tagMap = { + sfw: { id: 1, tag: 'sfw', normalized: 'sfw', badge: 'badge badge-success' }, + nsfw: { id: 2, tag: 'nsfw', normalized: 'nsfw', badge: 'badge badge-danger' }, + nsfl: { id: window.f0ckSession?.nsfl_tag_id || 3, tag: 'nsfl', normalized: 'nsfl', badge: 'badge badge-nsfl' } + }; + return tagMap[r] ? [tagMap[r]] : []; + }; + + // Seed data-parent-rating from the server-rendered rating badge + (() => { + const tagsContainer = document.querySelector('#tags'); + if (!tagsContainer) return; + const inner = tagsContainer.querySelector('.tags-inner') || tagsContainer; + const ratingEl = inner.querySelector('.rating-tag[data-rating]'); + if (!ratingEl) return; + const r = ratingEl.dataset.rating; + if (['sfw', 'nsfw', 'nsfl'].includes(r)) { + tagsContainer.dataset.parentRating = r; + } + })(); + const updateAlbumTags = () => { const sub = albumData[currentIndex]; if (!sub) return; @@ -3214,7 +3260,16 @@ window.cancelAnimFrame = (function () { tagScopeEl.innerHTML = ` Subf0ck ${subSlug} (${currentIndex + 1}/${albumData.length}):`; } - const subTags = Array.isArray(sub.tags) ? sub.tags : []; + let subTags = Array.isArray(sub.tags) ? sub.tags : []; + + // If this sub-item has no rating tag, inherit the parent album's rating + const RATING_NORMS = ['sfw', 'nsfw', 'nsfl']; + const subHasRating = subTags.some(t => RATING_NORMS.includes(t.normalized)); + if (!subHasRating) { + const parentRating = getParentRatingTags(); + if (parentRating.length > 0) subTags = [...parentRating, ...subTags]; + } + if (typeof window.renderTags === 'function') { window.renderTags(subTags); } @@ -3941,6 +3996,84 @@ window.cancelAnimFrame = (function () { } }; + // Paint background canvas immediately from thumbnail without network or animation delay + window.paintImmediateBgThumb = (thumbOrElem) => { + if (typeof background !== 'undefined' && !background) return; + const canvas = document.getElementById('bg'); + if (!canvas) return; + + let img = null; + if (thumbOrElem instanceof HTMLImageElement) { + img = thumbOrElem; + } else if (thumbOrElem && typeof thumbOrElem.querySelector === 'function') { + img = thumbOrElem.querySelector('img'); + } + if (!img) { + const activeEl = document.querySelector('.posts > a.thumb.onara-active img, .posts > a.thumb:focus img'); + if (activeEl) img = activeEl; + } + if (!img || !img.complete || (img.naturalWidth === 0 && img.width === 0)) return; + + const SCALE = 0.5; + const cw = Math.max(1, (canvas.clientWidth * SCALE) | 0); + const ch = Math.max(1, (canvas.clientHeight * SCALE) | 0); + if (canvas.width !== cw || canvas.height !== ch) { + canvas.width = cw; + canvas.height = ch; + } + + canvas._bgFadingOut = false; + canvas.classList.remove('fader-out', 'fast-fade'); + canvas.classList.add('fader-in'); + canvas.style.transition = 'none'; + canvas.style.opacity = 'var(--bg-canvas-opacity, 1.0)'; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const cfg = (typeof window.audioVisualizerTuning !== 'undefined' && window.audioVisualizerTuning) || (typeof DEFAULT_AUDIO_TUNING !== 'undefined' ? DEFAULT_AUDIO_TUNING : {}); + const method = cfg.bgBlurMethod !== undefined ? Number(cfg.bgBlurMethod) : 0; + const bSetting = cfg.bgCanvasBlur !== undefined ? Number(cfg.bgCanvasBlur) : 30; + const bPx = method === 1 ? 0 : Math.round(bSetting * SCALE); + const bVal = cfg.bgCanvasBrightness !== undefined ? Number(cfg.bgCanvasBrightness) : 0.45; + const sat = cfg.bgCanvasSaturate !== undefined ? Number(cfg.bgCanvasSaturate) : 1.8; + const con = cfg.bgCanvasContrast !== undefined ? Number(cfg.bgCanvasContrast) : 1.1; + const parts = []; + if (bPx > 0) parts.push(`blur(${bPx}px)`); + if (bVal !== 1) parts.push(`brightness(${bVal})`); + if (sat !== 1) parts.push(`saturate(${sat})`); + if (con !== 1) parts.push(`contrast(${con})`); + const filterStr = parts.length > 0 ? parts.join(' ') : 'none'; + + ctx.clearRect(0, 0, cw, ch); + ctx.save(); + ctx.filter = filterStr; + + let sw = img.naturalWidth || img.width; + let sh = img.naturalHeight || img.height; + if (sw > 0 && sh > 0) { + const scale = Math.max(cw / sw, ch / sh); + const dw = sw * scale; + const dh = sh * scale; + const dx = (cw - dw) / 2; + const dy = (ch - dh) / 2; + ctx.drawImage(img, dx, dy, dw, dh); + } else { + ctx.drawImage(img, 0, 0, cw, ch); + } + ctx.restore(); + + // Color overlay tint if configured + const colOp = cfg.bgCanvasColorOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.bgCanvasColorOpacity))) : 0; + if (colOp > 0.001) { + const col = cfg.bgCanvasColor || '#000000'; + ctx.save(); + ctx.fillStyle = colOp >= 0.999 ? col : `color-mix(in srgb, ${col} ${Math.round(colOp * 100)}%, transparent)`; + ctx.fillRect(0, 0, cw, ch); + ctx.restore(); + } + }; + // Export init function for dynamic calls window.initBackground = () => { // Media selection priority @@ -4094,6 +4227,18 @@ window.cancelAnimFrame = (function () { } }; + const applyBgColorOverlay = (ctx, w, h) => { + const cfg = window.audioVisualizerTuning || DEFAULT_AUDIO_TUNING; + const colOp = cfg.bgCanvasColorOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.bgCanvasColorOpacity))) : 0; + if (colOp > 0.001) { + const col = cfg.bgCanvasColor || '#000000'; + ctx.save(); + ctx.fillStyle = colOp >= 0.999 ? col : `color-mix(in srgb, ${col} ${Math.round(colOp * 100)}%, transparent)`; + ctx.fillRect(0, 0, w, h); + ctx.restore(); + } + }; + let crossfadeRafId = null; const crossfadeToSource = (sourceImg, onComplete) => { if (crossfadeRafId) window.cancelAnimFrame(crossfadeRafId); @@ -4103,6 +4248,7 @@ window.cancelAnimFrame = (function () { context.filter = getCanvasFilter(); drawCoverImage(context, sourceImg); context.restore(); + applyBgColorOverlay(context, cw, ch); if (onComplete) onComplete(); return; } @@ -4134,6 +4280,9 @@ window.cancelAnimFrame = (function () { } catch (e) {} context.restore(); + // 3. Color overlay tint if configured + applyBgColorOverlay(context, cw, ch); + if (progress < 1.0) { crossfadeRafId = window.requestAnimFrame(step); } else { @@ -4183,6 +4332,7 @@ window.cancelAnimFrame = (function () { } } }; + window.redrawBgCanvas = drawOnce; let videoFadeStartTime = prevSnapshot ? performance.now() : 0; const animationLoop = () => { @@ -4207,7 +4357,9 @@ window.cancelAnimFrame = (function () { context.save(); context.globalAlpha = ease; context.filter = getCanvasFilter(); - drawCoverImage(context, elem); + try { + drawCoverImage(context, elem); + } catch (e) {} context.restore(); if (progress >= 1.0) { @@ -4217,9 +4369,14 @@ window.cancelAnimFrame = (function () { // Video already loaded or resumed from pause: render directly at 100% opacity without fading from black context.save(); context.filter = getCanvasFilter(); - drawCoverImage(context, elem); + try { + drawCoverImage(context, elem); + } catch (e) {} context.restore(); } + + // Color overlay tint if configured + applyBgColorOverlay(context, cw, ch); } catch (e) { bgRafId = null; return; @@ -4418,11 +4575,59 @@ window.cancelAnimFrame = (function () { innerPupilRadius: 42, innerPupilRingOpacity: 0.99, innerPupilColor: "#000000", + enableVoidTunnel: 0, + voidTunnelRings: 10, + voidTunnelSpeed: 0.6, + voidTunnelReactivity: 3.0, + voidTunnelFadeWidth: 0.35, + voidTunnelUseAccent: 1, + voidTunnelColor: "#ffffff", + voidTunnelZone: 0, + voidTunnelRingWidth: 2.5, + voidTunnelEdgeFade: 0.35, + voidTunnelOpacity: 1.0, + voidTunnelDepth: 0.65, + voidTunnelZoomSpeed: 0.5, + // Square Corridor Tunnel + enableSquareTunnel: 0, + squareTunnelSpeed: 0.4, + squareTunnelReactivity: 1.5, + squareTunnelLevels: 6, + squareTunnelOpacity: 1.0, + squareTunnelGridWidth: 1.5, + squareTunnelWallFillAlpha: 1, + squareTunnelFog: 0.75, + squareTunnelGlow: 12, + squareTunnelWarp: 2.0, + squareTunnelUseAccent: 1, + squareTunnelGridColor: '#ffffff', + squareTunnelRoomScale: 1.5, + squareTunnelFOV: 0.55, + squareTunnelHideHUD: 0, + squareTunnelWallMode: 0, + squareTunnelWallColor: '#1a1a2e', + squareTunnelWallColor2: '#000000', + squareTunnelBackdropColor: '#000000', + squareTunnelBackdropBrightness: 0.55, + squareTunnelPatternScale: 40, + squareTunnelPatTileAlpha: 1, + squareTunnelMaze: 0, + squareTunnelTurnStrength: 0.6, + squareTunnelTurnSpeed: 0.04, + squareTunnelStraightBias: 0.6, + squareTunnelDriftFreq: 1.0, + squareTunnelReactDrift: 0.0, + squareTunnelReactScale: 0.18, + squareTunnelReactBright: 0.4, innerBarCount: 21, innerBarWidth: 12, innerBarHeight: 0.85, innerBarOpacity: 0.36, outerRingOpacity: 0, + eyeDisableGlow: 0, + eyeDisableShadow: 0, + eyeFOV: 600, + eyeZPos: 0, innerHighBoost: 0.34, innerRadialRotation: 90, innerBarGlow: 0, @@ -4452,6 +4657,8 @@ window.cancelAnimFrame = (function () { beatHueSpeed: 0.1, onaraBgOpacity: 0.7, onaraBackdropBlur: 5, + bgCanvasColor: '#000000', + bgCanvasColorOpacity: 0, bgCanvasOpacity: 1, bgBlurMethod: 0, bgCanvasBlur: 30, @@ -4464,7 +4671,7 @@ window.cancelAnimFrame = (function () { feedAudioToBg: 0 }; - const TUNING_CONFIG_VERSION = '2026-09-15_tuner_v6'; + const TUNING_CONFIG_VERSION = '2026-09-16_tuner_v7'; const getCustomDefaultTuning = () => { try { @@ -4513,6 +4720,9 @@ window.cancelAnimFrame = (function () { const bgBlur = c.bgCanvasBlur !== undefined ? Math.max(0, Number(c.bgCanvasBlur)) : 30; const method = c.bgBlurMethod !== undefined ? Number(c.bgBlurMethod) : 0; const zoom = c.bgCanvasZoom !== undefined ? Math.max(1.0, Number(c.bgCanvasZoom)) : 1.12; + const bgColor = c.bgCanvasColor || '#000000'; + const bgColOp = c.bgCanvasColorOpacity !== undefined ? Math.min(1, Math.max(0, Number(c.bgCanvasColorOpacity))) : 0.0; + const computedBgCol = bgColOp <= 0.001 ? 'transparent' : (bgColOp >= 0.999 ? bgColor : `color-mix(in srgb, ${bgColor} ${Math.round(bgColOp * 100)}%, transparent)`); document.documentElement.style.setProperty('--onara-bg-opacity', onaraOp.toFixed(3)); document.documentElement.style.setProperty('--bg-canvas-opacity', bgOp.toFixed(3)); @@ -4520,6 +4730,8 @@ window.cancelAnimFrame = (function () { document.documentElement.style.setProperty('--onara-backdrop-blur', `${onaraBlur}px`); document.documentElement.style.setProperty('--bg-canvas-blur', `${bgBlur}px`); document.documentElement.style.setProperty('--bg-canvas-zoom', zoom.toFixed(3)); + document.documentElement.style.setProperty('--bg-canvas-color', computedBgCol); + document.documentElement.style.setProperty('--bg-canvas-color-opacity', bgColOp.toFixed(3)); // Method 1 = GPU CSS filter (the classic look); Method 2 = Hybrid (CSS blur + canvas sat/contrast) if (method === 1 || method === 2) { @@ -4532,6 +4744,10 @@ window.cancelAnimFrame = (function () { if (bgEl) { bgEl.style.removeProperty('opacity'); } + + if (typeof window.redrawBgCanvas === 'function') { + window.redrawBgCanvas(); + } }; window.applyBackgroundOpacitySettings = applyBackgroundOpacitySettings; applyBackgroundOpacitySettings(window.audioVisualizerTuning); @@ -4738,8 +4954,55 @@ window.cancelAnimFrame = (function () { { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarWidth', label: 'Inner Bar Width', min: 1, max: 250, step: 1, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarOpacity', label: 'Inner Bar Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'outerRingOpacity', label: 'Outer Ring Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'eyeDisableGlow', label: 'Disable Accent Glow (1=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'eyeDisableShadow', label: 'Disable Drop Shadow (1=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'eyeFOV', label: 'Eye Perspective / FOV (lower=wider 3D tilt)', min: 100, max: 2000, step: 10, unit: 'px' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'eyeZPos', label: 'Eye Camera Distance — Z (positive=closer)', min: -200, max: 500, step: 5, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerPupilRingOpacity', label: 'Center Pupil Fill Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerPupilColor', label: 'Center Pupil Fill Color', type: 'color' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'enableVoidTunnel', label: 'Infinite Void Tunnel (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelRings', label: 'Void Tunnel Ring Count', min: 2, max: 30, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelSpeed', label: 'Void Tunnel Base Speed', min: 0.0, max: 5.0, step: 0.05, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelReactivity', label: 'Void Tunnel Audio Reactivity', min: 0.0, max: 10.0, step: 0.1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelFadeWidth', label: 'Void Tunnel Inner Fade Zone', min: 0.0, max: 1.0, step: 0.05, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelUseAccent', label: 'Void Tunnel Use Accent Color (1=Yes, 0=Custom)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelColor', label: 'Void Tunnel Custom Color', type: 'color' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelZone', label: 'Void Tunnel Zone (0=Inner Pupil, 1=Outer Iris, 2=Outer Shadow)', min: 0, max: 2, step: 1, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelRingWidth', label: 'Void Tunnel Ring Width', min: 0.1, max: 12.0, step: 0.1, unit: 'px' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelEdgeFade', label: 'Void Tunnel Edge Fade', min: 0.0, max: 0.9, step: 0.05, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelOpacity', label: 'Void Tunnel Max Opacity', min: 0.0, max: 1.0, step: 0.01, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelDepth', label: 'Void Tunnel Depth (glow + fog + warp)', min: 0.0, max: 1.0, step: 0.05, unit: '' }, + { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'voidTunnelZoomSpeed', label: 'Void Tunnel Zoom Speed (Zone 2)', min: 0.0, max: 5.0, step: 0.05, unit: '' }, + // Square Corridor Tunnel + { section: 'Square Corridor Tunnel', key: 'enableSquareTunnel', label: 'Enable Square Tunnel (1=On)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelSpeed', label: 'Fly Speed (rooms/sec)', min: 0.0, max: 12.0, step: 0.1, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelReactivity', label: 'Beat Speed Boost', min: 0.0, max: 20.0, step: 0.5, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelLevels', label: 'Rooms Visible Ahead', min: 2, max: 30, step: 1, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelRoomScale', label: 'Room Width (world units)', min: 0.3, max: 4.0, step: 0.05, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelFOV', label: 'Focal Length (FOV)', min: 0.05, max: 3.0, step: 0.05, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelOpacity', label: 'Opacity', min: 0.0, max: 1.0, step: 0.01, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelGridWidth', label: 'Grid Line Width', min: 0.0, max: 20.0, step: 0.5, unit: 'px' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelGlow', label: 'Grid Glow', min: 0, max: 120, step: 2, unit: 'px' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelUseAccent', label: 'Use Accent Color for Grid (1=On)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelGridColor', label: 'Grid Color (when Accent off)', type: 'color' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelFog', label: 'Depth Fog', min: 0.0, max: 1.0, step: 0.05, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelWallFillAlpha', label: 'Inner Corridor Darkness', min: 0, max: 1, step: 0.01, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelHideHUD', label: 'Hide Eye / Cover Circle HUD (1=On)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelWallMode', label: 'Wall Mode (0=Art, 1=Solid, 2=Checker, 3=V-Stripes, 4=Depth-Stripes)', min: 0, max: 4, step: 1, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelWallColor', label: 'Wall Color (Primary)', type: 'color' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelWallColor2', label: 'Wall Color (Secondary / Pattern)', type: 'color' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelBackdropColor', label: 'Backdrop / Inner Color', type: 'color' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelBackdropBrightness', label: 'Backdrop Art Brightness', min: 0.0, max: 1.5, step: 0.05, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelPatternScale', label: 'Pattern Tile Size', min: 8, max: 120, step: 4, unit: 'px' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelPatTileAlpha', label: 'Pattern Tile Opacity', min: 0, max: 1, step: 0.01, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelReactScale', label: 'Beat Room Scale Boost', min: 0.0, max: 2.0, step: 0.02, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelReactBright', label: 'Beat Brightness Flash', min: 0.0, max: 3.0, step: 0.05, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelMaze', label: 'Enable Spirit Drift (1=On)', min: 0, max: 1, step: 1, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelTurnStrength', label: 'Drift Intensity', min: 0.0, max: 1.2, step: 0.05, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelTurnSpeed', label: 'Drift Tracking Speed', min: 0.01, max: 0.3, step: 0.01, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelDriftFreq', label: 'Drift Wobble Frequency', min: 0.1, max: 5.0, step: 0.1, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelReactDrift', label: 'Beat Drift Kick', min: 0.0, max: 2.0, step: 0.05, unit: '' }, + { section: 'Square Corridor Tunnel', key: 'squareTunnelStraightBias', label: 'Straight-Ahead Probability', min: 0.0, max: 0.9, step: 0.05, unit: '' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerRadialRotation', label: 'Radial Iris Rotation Angle', min: 0, max: 360, step: 5, unit: '°' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerBarGlow', label: 'Inner Bar Glow', min: 0, max: 30, step: 1, unit: 'px' }, { section: 'Inner Eye Visualizer (Pupil HUD)', key: 'innerHideNote', label: 'Hide Music Note Icon (1=On, 0=Off)', min: 0, max: 1, step: 1, unit: '' }, @@ -4763,6 +5026,8 @@ window.cancelAnimFrame = (function () { const backgroundSliders = [ // Video & Ambient Background Canvas Section + { section: 'Video & Ambient Background Canvas', key: 'bgCanvasColor', label: 'Background Color', type: 'color' }, + { section: 'Video & Ambient Background Canvas', key: 'bgCanvasColorOpacity', label: 'Background Color Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Video & Ambient Background Canvas', key: 'bgCanvasOpacity', label: 'Background Canvas Opacity', min: 0.00, max: 1.00, step: 0.01, unit: '' }, { section: 'Video & Ambient Background Canvas', key: 'bgBlurMethod', label: 'Blur Engine (0=Canvas 2D, 1=GPU CSS, 2=Hybrid)', min: 0, max: 2, step: 1, unit: '' }, { section: 'Video & Ambient Background Canvas', key: 'bgCanvasBlur', label: 'Blur Radius', min: 0, max: 300, step: 1, unit: 'px' }, @@ -4867,15 +5132,27 @@ window.cancelAnimFrame = (function () { bgRowsHtml += `
${currentBgSection}
`; } const val = window.audioVisualizerTuning[s.key] !== undefined ? window.audioVisualizerTuning[s.key] : DEFAULT_AUDIO_TUNING[s.key]; - bgRowsHtml += ` -
-
- ${s.label} - ${val}${s.unit || ''} + if (s.type === 'color') { + bgRowsHtml += ` +
+
+ ${s.label} + ${val} +
+
- -
- `; + `; + } else { + bgRowsHtml += ` +
+
+ ${s.label} + ${val}${s.unit || ''} +
+ +
+ `; + } }); const danmakuCfg = window.danmakuTuning || (window.DEFAULT_DANMAKU_TUNING || {}); @@ -5415,9 +5692,13 @@ window.cancelAnimFrame = (function () { if (!input || !valEl) return; const val = window.audioVisualizerTuning[s.key] !== undefined ? window.audioVisualizerTuning[s.key] : DEFAULT_AUDIO_TUNING[s.key]; input.value = val; - const num = Number(val); - const displayVal = Number.isInteger(num) ? num : parseFloat(num.toFixed(3)); - valEl.textContent = `${displayVal}${s.unit || ''}`; + if (s.type === 'color') { + valEl.textContent = val; + } else { + const num = Number(val); + const displayVal = Number.isInteger(num) ? num : parseFloat(num.toFixed(3)); + valEl.textContent = `${displayVal}${s.unit || ''}`; + } }); const cfg = window.audioVisualizerTuning; @@ -5654,10 +5935,15 @@ window.cancelAnimFrame = (function () { const valEl = panel.querySelector(`#val-bg-${s.key}`); if (!input || !valEl) return; input.addEventListener('input', () => { - const parsed = parseFloat(input.value); - window.audioVisualizerTuning[s.key] = parsed; - const displayVal = Number.isInteger(parsed) ? parsed : parseFloat(parsed.toFixed(3)); - valEl.textContent = `${displayVal}${s.unit || ''}`; + if (s.type === 'color') { + window.audioVisualizerTuning[s.key] = input.value; + valEl.textContent = input.value; + } else { + const parsed = parseFloat(input.value); + window.audioVisualizerTuning[s.key] = parsed; + const displayVal = Number.isInteger(parsed) ? parsed : parseFloat(parsed.toFixed(3)); + valEl.textContent = `${displayVal}${s.unit || ''}`; + } applyBackgroundOpacitySettings(window.audioVisualizerTuning); @@ -5675,9 +5961,13 @@ window.cancelAnimFrame = (function () { const valEl = panel.querySelector(`#val-bg-${s.key}`); if (input) input.value = defaults[s.key]; if (valEl) { - const num = Number(defaults[s.key]); - const displayVal = Number.isInteger(num) ? num : parseFloat(num.toFixed(3)); - valEl.textContent = `${displayVal}${s.unit || ''}`; + if (s.type === 'color') { + valEl.textContent = defaults[s.key]; + } else { + const num = Number(defaults[s.key]); + const displayVal = Number.isInteger(num) ? num : parseFloat(num.toFixed(3)); + valEl.textContent = `${displayVal}${s.unit || ''}`; + } } }); applyBackgroundOpacitySettings(window.audioVisualizerTuning); @@ -6783,6 +7073,15 @@ window.cancelAnimFrame = (function () { coverCircle.insertBefore(innerCanvas, coverCircle.firstChild); } + // Outer shadow canvas — inside coverCircle (overflow:visible) so it extends beyond + // the circle boundary without being clipped by ph's overflow:hidden + if (!coverCircle.querySelector('.audio-void-outer-canvas')) { + const outerVoidCanvas = document.createElement('canvas'); + outerVoidCanvas.className = 'audio-void-outer-canvas'; + outerVoidCanvas.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);pointer-events:none;z-index:3;border-radius:0;display:none;'; + coverCircle.appendChild(outerVoidCanvas); + } + if (noteIcon) { noteIcon.style.display = Number(cfg.innerHideNote) === 1 ? 'none' : ''; } @@ -6873,6 +7172,449 @@ window.cancelAnimFrame = (function () { } if (coverCircle) { + // HUD hide: when square tunnel is active and squareTunnelHideHUD=1, fade out cover circle + const sqHideHUD = Number(cfg.enableSquareTunnel) === 1 && Number(cfg.squareTunnelHideHUD) === 1; + if (sqHideHUD) { + coverCircle.style.opacity = '0'; + coverCircle.style.pointerEvents = 'none'; + coverCircle.style.boxShadow = 'none'; // suppress accent glow bleedthrough + if (noteIcon) { noteIcon.style.opacity = '0'; noteIcon.style.pointerEvents = 'none'; } + // Hide any lingering trail ghosts + if (coverCircle._ghosts) coverCircle._ghosts.forEach(g => { if (g) g.style.opacity = '0'; }); + } else { + if (coverCircle.style.opacity === '0' && coverCircle._sqHiddenByTunnel) { + coverCircle.style.opacity = ''; + coverCircle.style.pointerEvents = ''; + coverCircle.style.boxShadow = ''; // restore dynamic glow + } + if (noteIcon && noteIcon.style.opacity === '0' && noteIcon._sqHiddenByTunnel) { + noteIcon.style.opacity = ''; + noteIcon.style.pointerEvents = ''; + } + } + coverCircle._sqHiddenByTunnel = sqHideHUD; + if (noteIcon) noteIcon._sqHiddenByTunnel = sqHideHUD; + + // ─── Square Corridor Tunnel (Infinity Zoom with Cover Art) ────────── + const enableSqTunnel = Number(cfg.enableSquareTunnel !== undefined ? cfg.enableSquareTunnel : 0) === 1; + let sqCanvas = ph ? ph.querySelector('.audio-sq-tunnel') : null; + if (!enableSqTunnel) { + if (sqCanvas) { + sqCanvas.getContext('2d').clearRect(0, 0, sqCanvas.width || 1, sqCanvas.height || 1); + sqCanvas.style.display = 'none'; + } + if (ph) ph.style.background = ''; // restore CSS default + } else { + if (ph && !sqCanvas) { + sqCanvas = document.createElement('canvas'); + sqCanvas.className = 'audio-sq-tunnel'; + sqCanvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;display:block;'; + ph.insertBefore(sqCanvas, ph.firstChild); + } + if (sqCanvas) { + sqCanvas.style.display = ''; + const sqW = ph.offsetWidth; + const sqH = ph.offsetHeight; + if (sqCanvas.width !== sqW) sqCanvas.width = sqW; + if (sqCanvas.height !== sqH) sqCanvas.height = sqH; + const sc = sqCanvas.getContext('2d'); + sc.clearRect(0, 0, sqW, sqH); + + // Config + const sqSpeedBase = cfg.squareTunnelSpeed !== undefined ? Number(cfg.squareTunnelSpeed) : 0.4; + const sqReact = cfg.squareTunnelReactivity !== undefined ? Number(cfg.squareTunnelReactivity) : 1.5; + const sqN = Math.max(3, Math.min(20, cfg.squareTunnelLevels !== undefined ? Number(cfg.squareTunnelLevels) : 10)); + const sqOpacity = cfg.squareTunnelOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.squareTunnelOpacity))) : 1.0; + const sqGridW = cfg.squareTunnelGridWidth !== undefined ? Number(cfg.squareTunnelGridWidth) : 1.5; + const sqFillAlpha = cfg.squareTunnelWallFillAlpha !== undefined ? Math.max(0, Math.min(1, Number(cfg.squareTunnelWallFillAlpha))) : 1; + const sqFog = cfg.squareTunnelFog !== undefined ? Math.max(0, Math.min(1, Number(cfg.squareTunnelFog))) : 0.75; + const sqGlow = cfg.squareTunnelGlow !== undefined ? Number(cfg.squareTunnelGlow) : 12; + const sqWarpExp = cfg.squareTunnelWarp !== undefined ? Number(cfg.squareTunnelWarp) : 2.0; + const sqUseAcc = cfg.squareTunnelUseAccent !== undefined ? Number(cfg.squareTunnelUseAccent) === 1 : true; + const sqGridColor = sqUseAcc ? accent : (cfg.squareTunnelGridColor || '#ffffff'); + + // Phase & timing + const sqNow = performance.now(); + if (!sqCanvas._t) sqCanvas._t = sqNow; + const sqDt = Math.min(0.1, (sqNow - sqCanvas._t) / 1000); + sqCanvas._t = sqNow; + // Camera advances along world Z; each integer unit = one cube room + sqCanvas._cameraZ = (sqCanvas._cameraZ || 0) + sqDt * (sqSpeedBase + kickEnergy * sqReact); + const sqCamZ = sqCanvas._cameraZ; + + // Room geometry + const sqRoomScale = cfg.squareTunnelRoomScale !== undefined ? Number(cfg.squareTunnelRoomScale) : 1.5; + const sqFocal = Math.min(sqW, sqH) * (cfg.squareTunnelFOV !== undefined ? Number(cfg.squareTunnelFOV) : 0.55); + + // Music reactivity + const sqReactScale = cfg.squareTunnelReactScale !== undefined ? Number(cfg.squareTunnelReactScale) : 0.18; + const sqReactBright = cfg.squareTunnelReactBright !== undefined ? Number(cfg.squareTunnelReactBright) : 0.4; + const sqReactDrift = cfg.squareTunnelReactDrift !== undefined ? Number(cfg.squareTunnelReactDrift) : 0.0; + const sqEffScale = sqRoomScale + kickEnergy * sqReactScale; + const sqBrightMult = 1.0 + kickEnergy * sqReactBright; + + // Maze turn config + const sqMaze = Number(cfg.squareTunnelMaze !== undefined ? cfg.squareTunnelMaze : 0) === 1; + const sqTurnStrength = cfg.squareTunnelTurnStrength !== undefined ? Number(cfg.squareTunnelTurnStrength) : 0.5; + const sqTurnSmooth = cfg.squareTunnelTurnSpeed !== undefined ? Number(cfg.squareTunnelTurnSpeed) : 0.12; + const sqDriftFreq = cfg.squareTunnelDriftFreq !== undefined ? Math.max(0.1, Number(cfg.squareTunnelDriftFreq)) : 1.0; + const sqBackdropBrightness = cfg.squareTunnelBackdropBrightness !== undefined ? Number(cfg.squareTunnelBackdropBrightness) : 0.55; + const sqStraightBias = cfg.squareTunnelStraightBias !== undefined ? Number(cfg.squareTunnelStraightBias) : 0.35; + + // Wall appearance + const sqWallMode = cfg.squareTunnelWallMode !== undefined ? Math.round(Number(cfg.squareTunnelWallMode)) : 0; + const sqWallC1 = cfg.squareTunnelWallColor || '#1a1a2e'; + const sqWallC2 = cfg.squareTunnelWallColor2 || '#000000'; + const sqBackdropColor = cfg.squareTunnelBackdropColor || '#000000'; + const sqPatScale = Math.max(8, Math.round(Number(cfg.squareTunnelPatternScale !== undefined ? cfg.squareTunnelPatternScale : 40))); + const sqPatTileAlpha = cfg.squareTunnelPatTileAlpha !== undefined ? Math.max(0, Math.min(1, Number(cfg.squareTunnelPatTileAlpha))) : 1; + + // Build / cache wall pattern (modes 2-4). Invalidate on any param change. + const patKey = `${sqWallMode}|${sqWallC1}|${sqWallC2}|${sqPatScale}`; + if (sqWallMode >= 2 && sqCanvas._wallPatKey !== patKey) { + sqCanvas._wallPatKey = patKey; + const ps = sqPatScale; + const pc = document.createElement('canvas'); + pc.width = ps * 2; + pc.height = ps * 2; + const px = pc.getContext('2d'); + px.fillStyle = sqWallC1; + px.fillRect(0, 0, ps * 2, ps * 2); + px.fillStyle = sqWallC2; + if (sqWallMode === 2) { // Checkerboard + px.fillRect(0, 0, ps, ps); + px.fillRect(ps, ps, ps, ps); + } else if (sqWallMode === 3) { // Vertical stripes + px.fillRect(0, 0, ps, ps * 2); + } else if (sqWallMode === 4) { // 45° diagonal stripes + px.beginPath(); + px.moveTo(0, 0); px.lineTo(ps, 0); px.lineTo(0, ps); px.closePath(); px.fill(); + px.beginPath(); + px.moveTo(ps, ps * 2); px.lineTo(ps * 2, ps); px.lineTo(ps * 2, ps * 2); px.closePath(); px.fill(); + } + sqCanvas._wallPattern = sc.createPattern(pc, 'repeat'); + } else if (sqWallMode < 2) { + sqCanvas._wallPattern = null; + } + const sqWallPattern = sqCanvas._wallPattern || null; + + // Resolve the wall fill for a given context (returns style string or pattern) + const sqWallFill = () => { + if (sqWallMode === 0) return null; // cover art handled separately + if (sqWallMode === 1) return sqWallC1; + return sqWallPattern || sqWallC1; + }; + + // Cover art image: try coverCircle background-image, then ph img tags + let sqImg = sqCanvas._sqImg; + let sqImgSrc = ''; + if (coverCircle) { + const bi = coverCircle.style.backgroundImage || ''; + const bm = bi.match(/url\(["']?([^"')]+)["']?\)/); + if (bm) sqImgSrc = bm[1]; + } + if (!sqImgSrc && ph) { + const imgEl = ph.querySelector('img:not([src$=".svg"])'); + if (imgEl && imgEl.src) sqImgSrc = imgEl.src; + } + if (sqImgSrc && sqCanvas._sqImgSrc !== sqImgSrc) { + const ni = new Image(); + ni.crossOrigin = 'anonymous'; + ni.src = sqImgSrc; + sqCanvas._sqImg = ni; + sqCanvas._sqImgSrc = sqImgSrc; + sqImg = ni; + } + const imgOk = sqImg && sqImg.complete && sqImg.naturalWidth > 0; + + const cx = sqW / 2, cy = sqH / 2; + const projH = (depth) => sqFocal * sqEffScale / Math.max(0.001, depth); + const sqCurRoom = Math.floor(sqCamZ); + const sqFrac = sqCamZ - sqCurRoom; + const sqMaxHalf = Math.max(sqW, sqH) * 2.5; + + // ── Spirit Drift: continuous noise-driven vanishing-point wander ────────── + // Instead of per-room binary turn/straight decisions the VP glides along + // two independent sine waves at irrational frequency ratios (so the pattern + // never repeats) layered over a slow random-walk bias. Result: organic, + // unpredictable motion that feels like a spirit drifting through space. + if (!sqCanvas._drift) { + sqCanvas._drift = { + t: Math.random() * 1000, // global time (seconds) + bx: 0, by: 0, // slow random-walk bias + bvx: 0, bvy: 0, // bias velocity + lastBeat: 0, // last beat kick time + }; + } + const drift = sqCanvas._drift; + const dt = 1 / 60; // assume ~60fps; visual-only so exact value unimportant + drift.t += dt; + + if (sqMaze) { + // --- Slow random-walk bias (drifts gradually in a random direction) --- + // Occasionally nudge the bias velocity + if (Math.random() < 0.004) { // ~once every 4 seconds at 60fps + drift.bvx += (Math.random() - 0.5) * 0.6; + drift.bvy += (Math.random() - 0.5) * 0.6; + } + // Drag the bias velocity toward zero so it doesn't diverge + drift.bvx *= 0.985; + drift.bvy *= 0.985; + // Integrate bias position, clamped so it doesn't dominate + drift.bx = Math.max(-1, Math.min(1, drift.bx + drift.bvx * dt)); + drift.by = Math.max(-1, Math.min(1, drift.by + drift.bvy * dt)); + + // --- Two-layer sine noise (incommensurable periods → never repeats) --- + // Layer A: slow wide sweep — base period ~18s/23s scaled by sqDriftFreq + const wA = sqDriftFreq / 18, hA = sqDriftFreq / 23; + const sinAxRaw = Math.sin(drift.t * wA * Math.PI * 2); + const sinAyRaw = Math.sin(drift.t * hA * Math.PI * 2); + // Layer B: faster subtle wobble (period ~7s x, ~11s y) + const wB = sqDriftFreq / 7, hB = sqDriftFreq / 11; + const sinBxRaw = Math.sin(drift.t * wB * Math.PI * 2) * 0.35; + const sinByRaw = Math.sin(drift.t * hB * Math.PI * 2) * 0.35; + // Combine layers + bias, keep in [-1, 1] + const noiseX = Math.max(-1, Math.min(1, sinAxRaw + sinBxRaw + drift.bx * 0.4)); + const noiseY = Math.max(-1, Math.min(1, sinAyRaw + sinByRaw + drift.by * 0.4)); + + // Beat impulse: edge-detect kick spikes and inject a directional lurch into + // the random-walk so each bass hit pushes the drift in its current direction. + const prevKick = drift.prevKick || 0; + const kickRise = kickEnergy - prevKick; + if (kickRise > 0.28 && kickEnergy > 0.45) { + // Push bias in the current noise direction — gives a sharp spatial "lurch" + drift.bvx += noiseX * kickRise * sqReactDrift * 2.2; + drift.bvy += noiseY * kickRise * sqReactDrift * 2.2; + } + drift.prevKick = kickEnergy; + + // VP target: drift noise (beat impulse above already baked into bx/by) + const sqTurnPx = sqTurnStrength * Math.min(sqW, sqH) * 0.38; + const tVpX = noiseX * sqTurnPx; + const tVpY = noiseY * sqTurnPx; + + // Store previous VP for velocity/banking calculation + sqCanvas._prevVpX = sqCanvas._vpX || 0; + sqCanvas._prevVpY = sqCanvas._vpY || 0; + + // Smooth lerp toward target (sqTurnSmooth controls how fast we track) + sqCanvas._vpX = ((sqCanvas._vpX || 0)) * (1 - sqTurnSmooth) + tVpX * sqTurnSmooth; + sqCanvas._vpY = ((sqCanvas._vpY || 0)) * (1 - sqTurnSmooth) + tVpY * sqTurnSmooth; + } else { + // Maze off: drift VP slowly back to centre + sqCanvas._vpX = ((sqCanvas._vpX || 0)) * 0.95; + sqCanvas._vpY = ((sqCanvas._vpY || 0)) * 0.95; + sqCanvas._drift = null; // reset so next enable gets a fresh seed + } + + const sqVpX = sqCanvas._vpX || 0; + const sqVpY = sqCanvas._vpY || 0; + const turnDepth = Math.max(1, sqN * 0.55); + + // Backdrop fill: opacity tracks sqOpacity so the whole tunnel (backdrop + + // walls + grid) fades as one when the Opacity slider is used. + // sqFillAlpha (Inner Corridor Darkness) controls room wall faces only. + sc.globalAlpha = sqOpacity; + sc.fillStyle = sqBackdropColor; + sc.fillRect(0, 0, sqW, sqH); + sc.globalAlpha = 1; + + + + // Draw rooms far→near (painter's algorithm) + for (let ri = sqN; ri >= 0; ri--) { + const roomIdx = sqCurRoom + ri; + const dBack = (roomIdx + 1) - sqCamZ; + const dFront = roomIdx - sqCamZ; + + if (dBack <= 0.001) continue; + + const hBack = Math.min(projH(dBack), sqMaxHalf); + const hFront = dFront > 0.001 ? Math.min(projH(dFront), sqMaxHalf) : sqMaxHalf; + + // Depth weights: 0 = camera position (no offset), 1 = full VP offset + const wBack = Math.min(1, dBack / turnDepth); + const wFront = dFront > 0.001 ? Math.min(1, dFront / turnDepth) : 0; + + // Per-room screen centers for back wall and front opening + const bvx = cx + sqVpX * wBack; + const bvy = cy + sqVpY * wBack; + const fvx = cx + sqVpX * wFront; + const fvy = cy + sqVpY * wFront; + + const fogMult = 1.0 - sqFog * Math.min(1.0, dBack / (sqN + 1)); + const wallA = Math.min(1, sqOpacity * fogMult * sqBrightMult); + + // Back wall: solid dark for pattern modes (pattern lives on corridor walls, not end-cap) + if (hBack > 0.5) { + sc.save(); + if (sqWallMode >= 2) { + sc.globalAlpha = wallA * 0.9 * sqFillAlpha; + sc.fillStyle = sqWallC2; + sc.fillRect(bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); + } else if (sqWallMode === 1) { + sc.globalAlpha = wallA * 0.9 * sqFillAlpha; + sc.fillStyle = sqWallC1; + sc.fillRect(bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); + } else if (imgOk) { + sc.globalAlpha = wallA * 0.9 * sqFillAlpha; + sc.drawImage(sqImg, bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); + } else { + sc.globalAlpha = wallA * 0.9 * sqFillAlpha; + sc.fillStyle = '#0a0a0a'; + sc.fillRect(bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); + } + sc.restore(); + } + + // Wall ring: draw the annular region between the front opening and the back wall + // using the evenodd fill rule. Outer path (CW) = front square, + // inner path (CCW) = back square → punches a hole, leaving only the wall area. + // This eliminates black-triangle artifacts that occur when shifted centers + // cause trapezoid quads to self-intersect. + if (hBack > 0.5 && hFront > hBack) { + const s0 = hBack, s1 = hFront; + + if (sqWallMode >= 2) { + // PERSPECTIVE-CORRECT wall pattern rendering. + // Each depth band covers equal PHYSICAL depth (physTile world-units). + // Screen positions are computed via the projection formula so bands + // compress correctly toward the vanishing point (true 1/z foreshortening). + // The x tile boundaries also scale with depth, converging to the VP. + const focalEff = sqFocal * sqEffScale; + const physTile = sqPatScale / focalEff; // physical tile size + const d0 = Math.max(dFront, 0.001); + const d1 = dBack; + // Number of tile columns across the full corridor width + const numAcross = Math.min(28, Math.max(2, + Math.round(2 * sqEffScale * focalEff / sqPatScale))); + const firstID = Math.floor(d0 / physTile); + const lastID = Math.ceil(d1 / physTile); + + // 4 faces: [axis (0=h, 1=v), sign (-1=top/left, +1=bottom/right)] + for (const [axis, sign] of [[0,-1],[0,1],[1,-1],[1,1]]) { + sc.save(); + // Clip to face trapezoid + sc.beginPath(); + if (axis === 0) { // top / bottom + sc.moveTo(fvx - s1, fvy + sign * s1); + sc.lineTo(fvx + s1, fvy + sign * s1); + sc.lineTo(bvx + s0, bvy + sign * s0); + sc.lineTo(bvx - s0, bvy + sign * s0); + } else { // left / right + sc.moveTo(fvx + sign * s1, fvy - s1); + sc.lineTo(fvx + sign * s1, fvy + s1); + sc.lineTo(bvx + sign * s0, bvy + s0); + sc.lineTo(bvx + sign * s0, bvy - s0); + } + sc.closePath(); + sc.clip(); + // Base fill with C1 at wall opacity × fill alpha + sc.globalAlpha = wallA * sqFillAlpha; + sc.fillStyle = sqWallC1; + sc.fillRect(0, 0, sqW, sqH); + // C2 tiles drawn at wallA × sqPatTileAlpha + sc.globalAlpha = wallA * sqPatTileAlpha; + sc.fillStyle = sqWallC2; + // Draw depth strips + for (let id = firstID; id <= lastID; id++) { + const da = Math.max(id * physTile, d0); + const db = Math.min((id + 1) * physTile, d1); + if (da >= db) continue; + const ha = Math.min(focalEff / da, s1 * 2); + const hb = Math.min(focalEff / db, s1 * 2); + const ta = (da - d0) / (d1 - d0); + const tb = (db - d0) / (d1 - d0); + // VP position at each depth (linear interp between front and back VP) + const vpxa = fvx + (bvx - fvx) * ta; + const vpya = fvy + (bvy - fvy) * ta; + const vpxb = fvx + (bvx - fvx) * tb; + const vpyb = fvy + (bvy - fvy) * tb; + for (let ia = 0; ia < numAcross; ia++) { + // Decide whether this cell is C2 + const drawC2 = sqWallMode === 2 + ? (id + ia) % 2 === 1 // checkerboard + : sqWallMode === 3 + ? ia % 2 === 1 // width stripes + : id % 2 === 1; // depth stripes + if (!drawC2) continue; + const fa = ia / numAcross, fb = (ia + 1) / numAcross; + let x0a, y0a, x1a, y1a, x0b, y0b, x1b, y1b; + if (axis === 0) { + // Horizontal face: face plane at y = vpy + sign*h + y0a = y1a = vpya + sign * ha; + y0b = y1b = vpyb + sign * hb; + x0a = vpxa + (2 * fa - 1) * ha; + x1a = vpxa + (2 * fb - 1) * ha; + x0b = vpxb + (2 * fa - 1) * hb; + x1b = vpxb + (2 * fb - 1) * hb; + } else { + // Vertical face: face plane at x = vpx + sign*h + x0a = x1a = vpxa + sign * ha; + x0b = x1b = vpxb + sign * hb; + y0a = vpya + (2 * fa - 1) * ha; + y1a = vpya + (2 * fb - 1) * ha; + y0b = vpyb + (2 * fa - 1) * hb; + y1b = vpyb + (2 * fb - 1) * hb; + } + sc.beginPath(); + sc.moveTo(x0a, y0a); + sc.lineTo(x1a, y1a); + sc.lineTo(x1b, y1b); + sc.lineTo(x0b, y0b); + sc.closePath(); + sc.fill(); + } + } + sc.restore(); + } + } else { + // Solid / cover art: fast evenodd ring (no per-face work needed) + sc.save(); + sc.globalAlpha = wallA * sqFillAlpha; + sc.beginPath(); + sc.moveTo(fvx - s1, fvy - s1); + sc.lineTo(fvx + s1, fvy - s1); + sc.lineTo(fvx + s1, fvy + s1); + sc.lineTo(fvx - s1, fvy + s1); + sc.closePath(); + sc.moveTo(bvx - s0, bvy - s0); + sc.lineTo(bvx - s0, bvy + s0); + sc.lineTo(bvx + s0, bvy + s0); + sc.lineTo(bvx + s0, bvy - s0); + sc.closePath(); + sc.clip('evenodd'); + if (sqWallMode === 1) { + sc.fillStyle = sqWallC1; + sc.fillRect(0, 0, sqW, sqH); + } else if (imgOk) { + sc.drawImage(sqImg, fvx - s1, fvy - s1, 2 * s1, 2 * s1); + } else { + sc.fillStyle = '#111'; + sc.fillRect(0, 0, sqW, sqH); + } + sc.restore(); + } + } + + // Grid frame on back wall + if (sqGridW > 0 && hBack > 0.5) { + sc.save(); + sc.strokeStyle = sqGridColor; + sc.lineWidth = sqGridW; + if (sqGlow > 0) { sc.shadowBlur = sqGlow; sc.shadowColor = sqGridColor; } + sc.globalAlpha = Math.min(1, wallA); + sc.beginPath(); + sc.rect(bvx - hBack, bvy - hBack, 2 * hBack, 2 * hBack); + sc.stroke(); + sc.shadowBlur = 0; + sc.restore(); + } + } + sc.globalAlpha = 1.0; + } + } + if (!sqHideHUD) { coverCircle._visualizerDriving = true; const circleScale = smoothScale; const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; @@ -6895,15 +7637,78 @@ window.cancelAnimFrame = (function () { const ringOp = cfg.outerRingOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.outerRingOpacity))) : 1.0; const borderPct = Math.min(100, Math.max(0, Math.round((0.35 + smoothGlow * 0.65) * 100 * ringOp))); - const curX = (coverCircle._eyeX || 0).toFixed(1); - const curY = (coverCircle._eyeY || 0).toFixed(1); + const curEyeX = coverCircle._eyeX || 0; + const curEyeY = coverCircle._eyeY || 0; + // Spirit drift: shift eye with the tunnel camera VP so they move as one + const driftOffX = (sqCanvas && sqCanvas._vpX) ? sqCanvas._vpX * 0.5 : 0; + const driftOffY = (sqCanvas && sqCanvas._vpY) ? sqCanvas._vpY * 0.5 : 0; + + // ── Flying feel ────────────────────────────────────────────────────────── + // 1. Bank: tilt into horizontal turns using VP velocity (rate of change per frame) + const vpVelX = (sqCanvas ? ((sqCanvas._vpX || 0) - (sqCanvas._prevVpX || 0)) : 0); + const bankDeg = Math.max(-18, Math.min(18, vpVelX * 0.55)); + // 2. Bob: slow sinusoidal vertical drift independent of the main wander + const drift2 = sqCanvas && sqCanvas._drift; + const bobY = drift2 ? Math.sin(drift2.t * 0.97 * Math.PI * 2) * 4.5 : 0; + // 3. Kick lunge: quick scale burst that decays each frame (stored on coverCircle) + if (!coverCircle._kickLunge) coverCircle._kickLunge = 0; + coverCircle._kickLunge += kickEnergy * 0.18; // spike on beat + coverCircle._kickLunge *= 0.78; // fast decay + const lungeBurst = Math.min(0.12, coverCircle._kickLunge); + + const curX = (curEyeX + driftOffX).toFixed(1); + const curY = (curEyeY + driftOffY + bobY).toFixed(1); const curTiltX = (coverCircle._eyeTiltX || 0).toFixed(1); const curTiltY = (coverCircle._eyeTiltY || 0).toFixed(1); - coverCircle.style.transform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(600px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) scale(${circleScale.toFixed(3)})`; - if (glowMult <= 0) { - coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9)`; - } else { - coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9), 0 0 ${glow1}px color-mix(in srgb, ${accent} ${pct1}%, transparent), 0 0 ${glow2}px color-mix(in srgb, ${accent} ${pct2}%, transparent), 0 0 ${glow3}px color-mix(in srgb, ${accent} ${pct3}%, transparent), 0 0 ${glow4}px color-mix(in srgb, ${accent} ${pct4}%, transparent)`; + const eyeFov = Math.round(cfg.eyeFOV !== undefined ? cfg.eyeFOV : 600); + const eyeZPos = cfg.eyeZPos !== undefined ? Number(cfg.eyeZPos) : 0; + const eyeTransform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(${eyeFov}px) translateZ(${eyeZPos}px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) rotateZ(${bankDeg.toFixed(2)}deg) scale(${(circleScale + lungeBurst).toFixed(3)})`; + coverCircle.style.transform = eyeTransform; + + // ── Motion trail ghosts ─────────────────────────────────────────────────── + // Ring buffer of past transforms; ghosts replay them with opacity + blur + const TRAIL_N = 5; + if (!coverCircle._trailBuf) coverCircle._trailBuf = []; + if (!coverCircle._ghosts) coverCircle._ghosts = []; + // Push current state onto the front + coverCircle._trailBuf.unshift(eyeTransform); + if (coverCircle._trailBuf.length > TRAIL_N + 1) coverCircle._trailBuf.pop(); + // Speed factor: how intense is the motion this frame (0..1) + const speedFactor = Math.min(1, Math.abs(bankDeg) / 10 + lungeBurst / 0.06 + Math.abs(vpVelX) * 0.04); + const coverBg = coverCircle.style.backgroundImage || ''; + // Per-ghost opacity and blur ramps + const ghostOpacity = [0.22, 0.14, 0.08, 0.04, 0.02]; + const ghostBlur = [3, 5, 8, 11, 15 ]; + for (let gi = 0; gi < TRAIL_N; gi++) { + // Create ghost element on first encounter + if (!coverCircle._ghosts[gi]) { + const g = document.createElement('div'); + g.className = 'eye-trail-ghost'; + ph.insertBefore(g, coverCircle); + coverCircle._ghosts[gi] = g; + } + const ghost = coverCircle._ghosts[gi]; + const pastTf = coverCircle._trailBuf[gi + 1]; + if (pastTf && speedFactor > 0.02) { + ghost.style.transform = pastTf; + ghost.style.backgroundImage = coverBg; + ghost.style.opacity = (speedFactor * ghostOpacity[gi]).toFixed(3); + ghost.style.filter = `blur(${ghostBlur[gi]}px)`; + } else { + ghost.style.opacity = '0'; + } + } + + const eyeNoGlow = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1; + const eyeNoShadow = Number(cfg.eyeDisableShadow !== undefined ? cfg.eyeDisableShadow : 0) === 1; + if (!coverCircle._sqHiddenByTunnel) { + if (eyeNoShadow) { + coverCircle.style.boxShadow = 'none'; + } else if (glowMult <= 0 || eyeNoGlow) { + coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9)`; + } else { + coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9), 0 0 ${glow1}px color-mix(in srgb, ${accent} ${pct1}%, transparent), 0 0 ${glow2}px color-mix(in srgb, ${accent} ${pct2}%, transparent), 0 0 ${glow3}px color-mix(in srgb, ${accent} ${pct3}%, transparent), 0 0 ${glow4}px color-mix(in srgb, ${accent} ${pct4}%, transparent)`; + } } if (ringOp <= 0.001) { coverCircle.style.borderColor = 'transparent'; @@ -6929,8 +7734,11 @@ window.cancelAnimFrame = (function () { const cX = coverCircle && coverCircle._eyeX ? Number(coverCircle._eyeX) : 0; const cY = coverCircle && coverCircle._eyeY ? Number(coverCircle._eyeY) : 0; - const focalX = `calc(50% + ${(cX * 0.5).toFixed(1)}px)`; - const focalY = `calc(50% + ${(cY * 0.5).toFixed(1)}px)`; + // Spirit drift: shift gradient focal point with the tunnel VP + const bgDriftX = (sqCanvas && sqCanvas._vpX) ? sqCanvas._vpX * 0.5 : 0; + const bgDriftY = (sqCanvas && sqCanvas._vpY) ? sqCanvas._vpY * 0.5 : 0; + const focalX = `calc(50% + ${(cX * 0.5 + bgDriftX).toFixed(1)}px)`; + const focalY = `calc(50% + ${(cY * 0.5 + bgDriftY).toFixed(1)}px)`; const dynamicSpread = Math.round(spreadPct * (1.0 + pulse * 0.45)); if (styleMode === 1) { @@ -7387,10 +8195,286 @@ window.cancelAnimFrame = (function () { inCtx.restore(); } } + // ─── Infinite Void Tunnel ─────────────────────────────────── + const enableVoid = Number(cfg.enableVoidTunnel !== undefined ? cfg.enableVoidTunnel : 0) === 1; + const vZone = cfg.voidTunnelZone !== undefined ? Number(cfg.voidTunnelZone) : 0; + + // Manage outer shadow canvas visibility + const outerVoidCanvas = ph ? ph.querySelector('.audio-void-outer-canvas') : null; + if (outerVoidCanvas) { + if (!enableVoid || vZone !== 2) { + // Clear and hide when not in use + if (outerVoidCanvas.width > 0) { + const oc = outerVoidCanvas.getContext('2d'); + oc.clearRect(0, 0, outerVoidCanvas.width, outerVoidCanvas.height); + } + outerVoidCanvas.style.display = 'none'; + } + } + + if (enableVoid) { + const vN = Math.max(2, Math.min(30, cfg.voidTunnelRings !== undefined ? Number(cfg.voidTunnelRings) : 10)); + const vBaseSpeed = cfg.voidTunnelSpeed !== undefined ? Number(cfg.voidTunnelSpeed) : 0.6; + const vReact = cfg.voidTunnelReactivity !== undefined ? Number(cfg.voidTunnelReactivity) : 3.0; + const vFade = Math.max(0, Math.min(0.99, cfg.voidTunnelFadeWidth !== undefined ? Number(cfg.voidTunnelFadeWidth) : 0.35)); + const vUseAccent = cfg.voidTunnelUseAccent !== undefined ? Number(cfg.voidTunnelUseAccent) === 1 : true; + const vColor = vUseAccent ? innerAccent : (cfg.voidTunnelColor || '#ffffff'); + const vRingW = cfg.voidTunnelRingWidth !== undefined ? Number(cfg.voidTunnelRingWidth) : 2.5; + const vEdgeFade = cfg.voidTunnelEdgeFade !== undefined ? Math.max(0, Math.min(0.99, Number(cfg.voidTunnelEdgeFade))) : 0.35; + const vOpacity = cfg.voidTunnelOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.voidTunnelOpacity))) : 1.0; + const vDepth = cfg.voidTunnelDepth !== undefined ? Math.max(0, Math.min(1, Number(cfg.voidTunnelDepth))) : 0.65; + // Higher warp exponent = stronger perspective compression near vanishing point + const vWarpExp = 1.8 + vDepth * 1.0; + + if (vZone === 2 && outerVoidCanvas) { + // ── Zone 2: Full Soak ────────────────────────────────────── + // Strategy: unified t→radius, clip-based partitioning. + // Inner canvas: full disc clip ≤ rMax → covers pupil + iris + // Outer canvas: annular clip rMaxDisp→edge → covers shadow/halo + // Both draw ALL rings; clipping decides which part is visible. + + // Shared geometry + const phW = ph ? ph.offsetWidth : (coverCircle.offsetWidth * 3); + const phH = ph ? ph.offsetHeight : phW; + const outerHalfPx = Math.sqrt(phW * phW + phH * phH) / 2; + const coverDispPx = coverCircle.offsetWidth || 125; + const innerPxPerDisp = (innerCanvas.width || 300) / coverDispPx; + const rMaxDisp = rMax / innerPxPerDisp; // rMax in display-px + + // Phase & timing + const vNow2 = performance.now(); + if (!outerVoidCanvas._voidLastTime) outerVoidCanvas._voidLastTime = vNow2; + const vDt2 = Math.min(0.1, (vNow2 - outerVoidCanvas._voidLastTime) / 1000); + outerVoidCanvas._voidLastTime = vNow2; + const vSpeed2 = vBaseSpeed + kickEnergy * vReact; + outerVoidCanvas._voidPhase = ((outerVoidCanvas._voidPhase || 0) + vDt2 * vSpeed2) % 1.0; + const vPhase2 = outerVoidCanvas._voidPhase; + + // Infinite zoom scale: K cycles 1→2 (at K=2 each ring matches the next → seamless) + const vZoomSpeed = cfg.voidTunnelZoomSpeed !== undefined ? Number(cfg.voidTunnelZoomSpeed) : 0.5; + if (!outerVoidCanvas._zoomK) outerVoidCanvas._zoomK = 1.0; + outerVoidCanvas._zoomK *= Math.exp((vZoomSpeed + kickEnergy * vReact * 0.1) * vDt2); + if (outerVoidCanvas._zoomK >= 2.0) outerVoidCanvas._zoomK /= 2.0; + const vZoomK = outerVoidCanvas._zoomK; + + // ── Part A: inner canvas — glow pass (no clip, shadows bleed freely) ── + if (vDepth > 0.01) { + inCtx.save(); + inCtx.translate(cx, cy); + inCtx.scale(vZoomK, vZoomK); + inCtx.translate(-cx, -cy); + inCtx.strokeStyle = vColor; + for (let vi = 0; vi < vN; vi++) { + const t = ((vi / vN) + vPhase2) % 1.0; + const tWarped = Math.pow(t, vWarpExp); + const ringR = tWarped * (rMax - 1); + if (ringR < 0.5) continue; + const fadeIn = t < vFade ? (t / vFade) : 1.0; + const edgeStart = 1.0 - vEdgeFade; + const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; + const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); + const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; + const lineW = Math.max(0.4, tWarped * vRingW); + const glowA = ringAlpha * vDepth * 0.55; + if (glowA > 0.01) { + inCtx.globalAlpha = Math.min(1, glowA); + inCtx.lineWidth = lineW * 3.5; + inCtx.shadowBlur = vDepth * 28 * tWarped; + inCtx.shadowColor = vColor; + inCtx.beginPath(); + inCtx.arc(cx, cy, ringR, 0, Math.PI * 2); + inCtx.stroke(); + } + } + inCtx.shadowBlur = 0; + inCtx.restore(); + } + + // ── Part A: inner canvas — core pass (clipped to rMax) ──── + inCtx.save(); + inCtx.beginPath(); + inCtx.arc(cx, cy, rMax - 1, 0, Math.PI * 2, false); + inCtx.clip(); + inCtx.translate(cx, cy); + inCtx.scale(vZoomK, vZoomK); + inCtx.translate(-cx, -cy); + inCtx.strokeStyle = vColor; + inCtx.shadowBlur = 0; + for (let vi = 0; vi < vN; vi++) { + const t = ((vi / vN) + vPhase2) % 1.0; + const tWarped = Math.pow(t, vWarpExp); + const ringR = tWarped * (rMax - 1); + if (ringR < 0.5) continue; + const fadeIn = t < vFade ? (t / vFade) : 1.0; + const edgeStart = 1.0 - vEdgeFade; + const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; + const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); + const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; + const lineW = Math.max(0.4, tWarped * vRingW); + inCtx.globalAlpha = Math.min(1, ringAlpha); + inCtx.lineWidth = lineW; + inCtx.beginPath(); + inCtx.arc(cx, cy, ringR, 0, Math.PI * 2); + inCtx.stroke(); + } + inCtx.restore(); + + // ── Part B: outer canvas — annular clip rMaxDisp→edge ───── + const rMaxOuter = rMaxDisp; // display-px == outer-canvas-px (1:1 mapping) + outerVoidCanvas.style.transform = `translate(-50%,-50%) scale(${vZoomK})`; + const outerSize = Math.round(Math.sqrt(phW * phW + phH * phH)); + if (outerVoidCanvas.width !== outerSize || outerVoidCanvas.height !== outerSize) { + outerVoidCanvas.width = outerSize; + outerVoidCanvas.height = outerSize; + outerVoidCanvas.style.width = outerSize + 'px'; + outerVoidCanvas.style.height = outerSize + 'px'; + } + outerVoidCanvas.style.display = ''; + + const oc = outerVoidCanvas.getContext('2d'); + const ocx = outerSize / 2; + const ocy = outerSize / 2; + oc.clearRect(0, 0, outerSize, outerSize); + + // ── Part B: outer canvas — glow pass (no clip) ─────────── + if (vDepth > 0.01) { + oc.save(); + oc.strokeStyle = vColor; + for (let vi = 0; vi < vN; vi++) { + const t = ((vi / vN) + vPhase2) % 1.0; + const tWarped = Math.pow(t, vWarpExp); + const ringR = tWarped * outerHalfPx; + if (ringR < rMaxOuter - 1) continue; // skip rings inside the inner canvas region + if (ringR < 0.5) continue; + const fadeIn = t < vFade ? (t / vFade) : 1.0; + const edgeStart = 1.0 - vEdgeFade; + const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; + const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); + const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; + const lineW = Math.max(0.4, tWarped * vRingW); + const glowA = ringAlpha * vDepth * 0.55; + if (glowA > 0.01) { + oc.globalAlpha = Math.min(1, glowA); + oc.lineWidth = lineW * 3.5; + oc.shadowBlur = vDepth * 28 * tWarped; + oc.shadowColor = vColor; + oc.beginPath(); + oc.arc(ocx, ocy, ringR, 0, Math.PI * 2); + oc.stroke(); + } + } + oc.shadowBlur = 0; + oc.restore(); + } + + // ── Part B: outer canvas — core pass (annular clip) ────── + oc.save(); + oc.beginPath(); + oc.arc(ocx, ocy, outerSize, 0, Math.PI * 2, false); + oc.arc(ocx, ocy, rMaxOuter, 0, Math.PI * 2, true); + oc.clip('evenodd'); + oc.strokeStyle = vColor; + oc.shadowBlur = 0; + for (let vi = 0; vi < vN; vi++) { + const t = ((vi / vN) + vPhase2) % 1.0; + const tWarped = Math.pow(t, vWarpExp); + const ringR = tWarped * outerHalfPx; + if (ringR < 0.5) continue; + const fadeIn = t < vFade ? (t / vFade) : 1.0; + const edgeStart = 1.0 - vEdgeFade; + const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; + const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); + const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; + const lineW = Math.max(0.4, tWarped * vRingW); + oc.globalAlpha = Math.min(1, ringAlpha); + oc.lineWidth = lineW; + oc.beginPath(); + oc.arc(ocx, ocy, ringR, 0, Math.PI * 2); + oc.stroke(); + } + oc.restore(); + oc.globalAlpha = 1.0; + + } else { + // ── Zone 0 / 1: draw on inner canvas (inCtx) ── + const vClipOuterR = vZone === 0 ? rMin - 2 : rMax - 1; + const vSpan = vClipOuterR; + + if (vSpan > 4) { + const vNow = performance.now(); + if (!innerCanvas._voidLastTime) innerCanvas._voidLastTime = vNow; + const vDt = Math.min(0.1, (vNow - innerCanvas._voidLastTime) / 1000); + innerCanvas._voidLastTime = vNow; + const vSpeed = vBaseSpeed + kickEnergy * vReact; + innerCanvas._voidPhase = ((innerCanvas._voidPhase || 0) + vDt * vSpeed) % 1.0; + const vPhase = innerCanvas._voidPhase; + + // Glow pass — separate unclipped save block so shadowBlur isn't truncated + if (vDepth > 0.01) { + inCtx.save(); + inCtx.strokeStyle = vColor; + for (let vi = 0; vi < vN; vi++) { + const t = ((vi / vN) + vPhase) % 1.0; + const tWarped = Math.pow(t, vWarpExp); + const ringR = tWarped * vSpan; + if (ringR < 0.5) continue; + const fadeIn = t < vFade ? (t / vFade) : 1.0; + const edgeStart = 1.0 - vEdgeFade; + const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; + const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); + const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; + const lineW = Math.max(0.4, tWarped * vRingW); + const glowA = ringAlpha * vDepth * 0.55; + if (glowA > 0.01) { + inCtx.globalAlpha = Math.min(1, glowA); + inCtx.lineWidth = lineW * 3.5; + inCtx.shadowBlur = vDepth * 28 * tWarped; + inCtx.shadowColor = vColor; + inCtx.beginPath(); + inCtx.arc(cx, cy, ringR, 0, Math.PI * 2); + inCtx.stroke(); + } + } + inCtx.shadowBlur = 0; + inCtx.restore(); + } + + // Core pass — clipped + inCtx.save(); + inCtx.shadowBlur = 0; + inCtx.beginPath(); + inCtx.arc(cx, cy, vClipOuterR, 0, Math.PI * 2, false); + inCtx.clip(); + inCtx.strokeStyle = vColor; + for (let vi = 0; vi < vN; vi++) { + const t = ((vi / vN) + vPhase) % 1.0; + const tWarped = Math.pow(t, vWarpExp); + const ringR = tWarped * vSpan; + if (ringR < 0.5) continue; + const fadeIn = t < vFade ? (t / vFade) : 1.0; + const edgeStart = 1.0 - vEdgeFade; + const fadeOut = t > edgeStart ? ((1.0 - t) / vEdgeFade) : 1.0; + const depthFog = 1.0 - vDepth * 0.65 * (1.0 - t); + const ringAlpha = vOpacity * fadeIn * fadeOut * depthFog; + const lineW = Math.max(0.4, tWarped * vRingW); + inCtx.globalAlpha = Math.min(1, ringAlpha); + inCtx.lineWidth = lineW; + inCtx.beginPath(); + inCtx.arc(cx, cy, ringR, 0, Math.PI * 2); + inCtx.stroke(); + } + inCtx.restore(); + } + } + } + // ──────────────────────────────────────────────────────────── + inCtx.shadowBlur = 0; inCtx.globalAlpha = 1.0; } } + // ──────────────────────────────────────────────────────────────────── + } // end if (!sqHideHUD) } } else { // Decay smoothly back to neutral state when paused or idle @@ -7398,11 +8482,22 @@ window.cancelAnimFrame = (function () { bgEl.style.opacity = '0'; } if (coverCircle) { + // Restore HUD if it was hidden by the tunnel + if (coverCircle._sqHiddenByTunnel) { + coverCircle.style.opacity = ''; + coverCircle.style.pointerEvents = ''; + coverCircle._sqHiddenByTunnel = false; + } const inC = coverCircle.querySelector('.audio-eye-inner-canvas'); if (inC) { const inCtx = inC.getContext('2d'); if (inCtx) inCtx.clearRect(0, 0, inC.width, inC.height); } + if (noteIcon && noteIcon._sqHiddenByTunnel) { + noteIcon.style.opacity = ''; + noteIcon.style.pointerEvents = ''; + noteIcon._sqHiddenByTunnel = false; + } } if (smoothScale > 1.002 || smoothGlow > 0.005) { smoothScale += (1 - smoothScale) * 0.16; @@ -7417,11 +8512,19 @@ window.cancelAnimFrame = (function () { } } if (coverCircle) { - const curX = (coverCircle._eyeX || 0).toFixed(1); - const curY = (coverCircle._eyeY || 0).toFixed(1); + const curEyeX2 = coverCircle._eyeX || 0; + const curEyeY2 = coverCircle._eyeY || 0; + // Spirit drift: shift eye with the tunnel camera VP so they move as one + const _sqCv2 = ph && ph.querySelector('.audio-sq-tunnel'); + const driftOffX2 = (_sqCv2 && _sqCv2._vpX) ? _sqCv2._vpX * 0.5 : 0; + const driftOffY2 = (_sqCv2 && _sqCv2._vpY) ? _sqCv2._vpY * 0.5 : 0; + const curX = (curEyeX2 + driftOffX2).toFixed(1); + const curY = (curEyeY2 + driftOffY2).toFixed(1); const curTiltX = (coverCircle._eyeTiltX || 0).toFixed(1); const curTiltY = (coverCircle._eyeTiltY || 0).toFixed(1); - coverCircle.style.transform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(600px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) scale(${smoothScale.toFixed(3)})`; + const eyeFov2 = Math.round(cfg.eyeFOV !== undefined ? cfg.eyeFOV : 600); + const eyeZPos2 = cfg.eyeZPos !== undefined ? Number(cfg.eyeZPos) : 0; + coverCircle.style.transform = `translate(calc(-50% + ${curX}px), calc(-50% + ${curY}px)) perspective(${eyeFov2}px) translateZ(${eyeZPos2}px) rotateX(${curTiltX}deg) rotateY(${curTiltY}deg) scale(${smoothScale.toFixed(3)})`; const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; const glowReach = cfg.glowIntensity !== undefined ? cfg.glowIntensity : 250; const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; @@ -7433,10 +8536,16 @@ window.cancelAnimFrame = (function () { const pct1 = Math.min(100, Math.max(0, Math.round((0.36 + smoothGlow * 0.64) * bMult * 100))); const pct2 = Math.min(100, Math.max(0, Math.round((0.22 + smoothGlow * 0.72) * bMult * 100))); const pct3 = Math.min(100, Math.max(0, Math.round((0.10 + smoothGlow * 0.68) * bMult * 100))); - if (glowMult <= 0) { - coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9)`; - } else { - coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9), 0 0 ${glow1}px color-mix(in srgb, ${accent} ${pct1}%, transparent), 0 0 ${glow2}px color-mix(in srgb, ${accent} ${pct2}%, transparent), 0 0 ${glow3}px color-mix(in srgb, ${accent} ${pct3}%, transparent)`; + const eyeNoGlowB = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1; + const eyeNoShadowB = Number(cfg.eyeDisableShadow !== undefined ? cfg.eyeDisableShadow : 0) === 1; + if (!coverCircle._sqHiddenByTunnel) { + if (eyeNoShadowB) { + coverCircle.style.boxShadow = 'none'; + } else if (glowMult <= 0 || eyeNoGlowB) { + coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9)`; + } else { + coverCircle.style.boxShadow = `0 14px 45px rgba(0, 0, 0, 0.9), 0 0 ${glow1}px color-mix(in srgb, ${accent} ${pct1}%, transparent), 0 0 ${glow2}px color-mix(in srgb, ${accent} ${pct2}%, transparent), 0 0 ${glow3}px color-mix(in srgb, ${accent} ${pct3}%, transparent)`; + } } } } else if (smoothScale !== 1 || smoothGlow !== 0) { @@ -7450,10 +8559,16 @@ window.cancelAnimFrame = (function () { coverCircle._visualizerDriving = false; const baseGlow = cfg.coverGlowBase !== undefined ? cfg.coverGlowBase : 35; const glowMult = cfg.glowBrightness !== undefined ? cfg.glowBrightness : 1.0; - if (glowMult <= 0) { - coverCircle.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88)`; - } else { - coverCircle.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88), 0 0 ${baseGlow}px color-mix(in srgb, ${accent} ${Math.round(28 * Math.min(1.5, glowMult))}%, transparent)`; + const eyeNoGlowC = Number(cfg.eyeDisableGlow !== undefined ? cfg.eyeDisableGlow : 0) === 1; + const eyeNoShadowC = Number(cfg.eyeDisableShadow !== undefined ? cfg.eyeDisableShadow : 0) === 1; + if (!coverCircle._sqHiddenByTunnel) { + if (eyeNoShadowC) { + coverCircle.style.boxShadow = 'none'; + } else if (glowMult <= 0 || eyeNoGlowC) { + coverCircle.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88)`; + } else { + coverCircle.style.boxShadow = `0 12px 40px rgba(0, 0, 0, 0.88), 0 0 ${baseGlow}px color-mix(in srgb, ${accent} ${Math.round(28 * Math.min(1.5, glowMult))}%, transparent)`; + } } const ringOp = cfg.outerRingOpacity !== undefined ? Math.min(1, Math.max(0, Number(cfg.outerRingOpacity))) : 1.0; if (ringOp <= 0.001) { @@ -15513,6 +16628,14 @@ class NotificationSystem { onaraThumb.setAttribute('data-mode', sseMode); } } + + // If this is a parent-level tags update (no subf0ck_id), persist the rating + // into data-parent-rating so the album gallery can inherit it for unrated sub-items. + if (!data.subf0ck_id && tagsContainer && rTag) { + tagsContainer.dataset.parentRating = rTag.normalized; + } else if (!data.subf0ck_id && tagsContainer) { + delete tagsContainer.dataset.parentRating; + } } } @@ -15716,7 +16839,7 @@ class NotificationSystem { const ghostTitle = i18n.filtered_upload_title || 'Your upload'; const ghostHint = i18n.filtered_upload_hint || 'Hidden by filter · click to view'; - const nsflId = window.f0ckSession?.nsfl_tag_id; + const nsflId = window.f0ckSession?.nsfl_tag_id || 3; const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null'; const ghost = document.createElement('a'); @@ -15761,7 +16884,7 @@ class NotificationSystem { const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/'; // Respect mode filter - const nsflId = window.f0ckSession?.nsfl_tag_id; + const nsflId = window.f0ckSession?.nsfl_tag_id || 3; const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null'; const thumb = document.createElement('a'); @@ -15777,7 +16900,14 @@ class NotificationSystem { thumb.style.setProperty('--thumb-bg', `url('/t/${data.id}.webp')`); thumb.classList.add('loaded'); thumb.style.transform = 'scale(0.9)'; - thumb.innerHTML = '

' + (data.is_oc ? 'OC' : ''); + + // Build thumb-indicators (matches server-rendered template structure) + let indicatorsHtml = ''; + if (data.is_oc) indicatorsHtml += 'OC'; + if (data.is_album && (data.album_count || 0) > 1) { + indicatorsHtml += ` ${data.album_count}`; + } + thumb.innerHTML = `
${indicatorsHtml}

`; // Apply pending notification highlight if notify SSE arrived before new_item if (this.pendingNotifIds.has(String(data.id))) { diff --git a/public/s/js/settings.js b/public/s/js/settings.js index aa4f67d..1240e4b 100644 --- a/public/s/js/settings.js +++ b/public/s/js/settings.js @@ -1585,19 +1585,25 @@ }); }; - setupAudioTuningSlider('onara_bg_opacity_slider', 'onara_bg_opacity_val', 'onaraBgOpacity', 0.70, v => Math.round(v * 100) + '%'); - setupAudioTuningSlider('onara_backdrop_blur_slider', 'onara_backdrop_blur_val', 'onaraBackdropBlur', 5, v => Math.round(v) + 'px'); - setupAudioTuningSlider('onara_grid_dim_slider', 'onara_grid_dim_val', 'onaraGridDim', 0.38, v => Math.round(v * 100) + '%'); - setupAudioTuningSlider('bg_canvas_opacity_slider', 'bg_canvas_opacity_val', 'bgCanvasOpacity', 1.00, v => Math.round(v * 100) + '%'); - setupAudioTuningSlider('bg_canvas_blur_slider', 'bg_canvas_blur_val', 'bgCanvasBlur', 30, v => Math.round(v) + 'px'); - setupAudioTuningSlider('bg_canvas_brightness_slider', 'bg_canvas_brightness_val', 'bgCanvasBrightness', 0.45, v => Number(v).toFixed(2) + 'x'); - setupAudioTuningSlider('bg_canvas_saturate_slider', 'bg_canvas_saturate_val', 'bgCanvasSaturate', 1.8, v => Number(v).toFixed(2) + 'x'); - setupAudioTuningSlider('bg_canvas_contrast_slider', 'bg_canvas_contrast_val', 'bgCanvasContrast', 1.1, v => Number(v).toFixed(2) + 'x'); - setupAudioTuningSlider('bg_canvas_zoom_slider', 'bg_canvas_zoom_val', 'bgCanvasZoom', 1.12, v => Number(v).toFixed(2) + 'x'); - // Blur Engine slider — shows friendly label text instead of raw number - const blurMethodNames = ['Canvas 2D', 'GPU CSS Filter', 'Hybrid']; - setupAudioTuningSlider('bg_blur_method_slider', 'bg_blur_method_val', 'bgBlurMethod', 0, v => blurMethodNames[Math.round(v)] || String(Math.round(v)), v => Math.round(Number(v))); + // Onara enable/disable toggle (localStorage only — no server round-trip needed) + const onaraEnabledToggle = document.getElementById('onara_enabled_toggle'); + if (onaraEnabledToggle) { + const LS_KEY = 'f0ck_onara_enabled'; + // Seed checkbox from localStorage; fall back to the server-set session value + const stored = localStorage.getItem(LS_KEY); + const sessionOnara = !!(window.f0ckSession?.onara); + onaraEnabledToggle.checked = stored !== null ? stored === 'true' : sessionOnara; + // Reflect current state into window.onara so isOnaraActive() sees it + window.onara = onaraEnabledToggle.checked; + + onaraEnabledToggle.addEventListener('change', () => { + const enabled = onaraEnabledToggle.checked; + localStorage.setItem(LS_KEY, String(enabled)); + window.onara = enabled; + showStatus('Onara viewer ' + (enabled ? 'enabled' : 'disabled') + '.', 'success'); + }); + } // Quote Emojis Toggle const quoteEmojisToggle = document.getElementById('quote_emojis_toggle'); diff --git a/public/s/js/upload.js b/public/s/js/upload.js index d4d1e17..5888395 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -198,7 +198,9 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => { tag_id: item.rating_tag_id || item.tag_id || 0, is_oc: !!item.is_oc, slug: item.slug, - visibility: item.visibility || 0 + visibility: item.visibility || 0, + is_album: !!item.is_album, + album_count: item.album_count || 0 }); } }) @@ -3068,7 +3070,9 @@ window.initUploadForm = (selector) => { tag_id: res.tag_id ?? 0, is_oc: !!res.is_oc, slug: res.slug, - visibility: res.visibility || 0 + visibility: res.visibility || 0, + is_album: !!res.is_album, + album_count: res.album_count || 0 }); } @@ -3320,7 +3324,9 @@ window.initUploadForm = (selector) => { tag_id: up.tag_id ?? 0, is_oc: !!up.is_oc, slug: up.slug, - visibility: up.visibility || 0 + visibility: up.visibility || 0, + is_album: !!up.is_album, + album_count: up.album_count || 0 }); } } diff --git a/public/s/js/user.js b/public/s/js/user.js index c650cd0..1cfb19d 100644 --- a/public/s/js/user.js +++ b/public/s/js/user.js @@ -103,12 +103,30 @@ if (canManage) { span.classList.add('can-cycle'); } - } else if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) { - span.setAttribute('tooltip', tag.display_name || tag.user); + } else { + span.classList.add('tag-badge'); + span.setAttribute('data-tag-id', tag.id || ''); + span.setAttribute('data-tag', tag.tag); + span.setAttribute('data-tag-normalized', tag.normalized); + if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) { + span.setAttribute('tooltip', tag.display_name || tag.user); + } + const isExcl = !!tag.is_excluded; + if (isExcl) span.classList.add('tag-is-excluded'); } span.insertAdjacentElement("beforeend", contentEl); + if (!isRating && tag.can_exclude) { + const excludeBtn = document.createElement('button'); + excludeBtn.type = 'button'; + excludeBtn.className = 'tag-exclude-btn'; + excludeBtn.setAttribute('title', tag.is_excluded ? 'Excluded (click to unexclude)' : 'Exclude tag'); + excludeBtn.setAttribute('aria-label', 'Exclude tag'); + excludeBtn.innerHTML = ``; + span.insertAdjacentElement('beforeend', excludeBtn); + } + if (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator) && !isRating) { const space = document.createTextNode('\u00A0'); //   span.appendChild(space); diff --git a/src/inc/routes/apiv2/tags.mjs b/src/inc/routes/apiv2/tags.mjs index df8d0f8..d23b43e 100644 --- a/src/inc/routes/apiv2/tags.mjs +++ b/src/inc/routes/apiv2/tags.mjs @@ -503,6 +503,26 @@ export default router => { VALUES (${postid}, ${nextTagId}, ${+req.session.id}) `; } + + // Propagate rating to all album sub-items if this is an album + const albumCheck = await sql`SELECT id FROM items WHERE id = ${postid} AND is_album = true LIMIT 1`; + if (albumCheck.length > 0) { + const subItems = await sql`SELECT id FROM album_items WHERE item_id = ${postid}`; + for (const sub of subItems) { + await sql` + DELETE FROM album_items_tags_assign + WHERE album_item_id = ${sub.id} + AND tag_id IN (1, 2, ${nsflId}) + `; + if (nextTagId > 0) { + await sql` + INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id) + VALUES (${sub.id}, ${nextTagId}, ${+req.session.id}) + ON CONFLICT DO NOTHING + `; + } + } + } } // Automatically generate/verify blurred thumbnail on cycle diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index d7ab127..cbc4e36 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -534,7 +534,9 @@ export default router => { tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, is_oc: !!is_oc, slug: itemSlug, - visibility: targetVisibility + visibility: targetVisibility, + is_album: false, + album_count: 0 })})`; } catch (err) { console.error('[UPLOAD-URL] YouTube new_item notify failed:', err); @@ -869,7 +871,9 @@ export default router => { tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, is_oc: !!is_oc, slug: itemSlug, - visibility: targetVisibility + visibility: targetVisibility, + is_album: false, + album_count: 0 })})`; } catch (err) { console.error('[UPLOAD-URL] new_item notify failed:', err); diff --git a/src/inc/routes/external.mjs b/src/inc/routes/external.mjs index 95ac27e..a5cedf4 100644 --- a/src/inc/routes/external.mjs +++ b/src/inc/routes/external.mjs @@ -493,7 +493,9 @@ export default (router) => { username: session.user, display_name: session.display_name || null, tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)), - is_oc: false + is_oc: false, + is_album: false, + album_count: 0 })})`; } catch (err) { console.error('[REHOST] new_item notify failed:', err); diff --git a/src/inc/routes/mod.mjs b/src/inc/routes/mod.mjs index e58a9bb..42d8c31 100644 --- a/src/inc/routes/mod.mjs +++ b/src/inc/routes/mod.mjs @@ -114,9 +114,12 @@ export default (router, tpl) => { } const f0ck = await db` - select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id + select i.dest, i.mime, i.username, i.id, i.visibility, i.is_album, i.album_count, + (select ta2.tag_id from tags_assign ta2 + join tags t2 on t2.id = ta2.tag_id + where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl') + limit 1) as tag_id from "items" i - left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2) where i.id = ${id} and i.active = false limit 1 `; @@ -217,7 +220,9 @@ export default (router, tpl) => { mime: f0ck[0].mime, username: f0ck[0].username, tag_id: f0ck[0].tag_id, - is_oc: !!f0ck[0].is_oc + is_oc: !!f0ck[0].is_oc, + is_album: !!f0ck[0].is_album, + album_count: f0ck[0].album_count || 0 })})`; } catch (err) { console.error('[MOD APPROVE] new_item notify failed:', err); diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index cceb6a9..2e75993 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -463,7 +463,9 @@ export const handleUpload = async (req, res, self) => { tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, is_oc: !!is_oc, slug: itemSlug, - visibility: targetVisibility + visibility: targetVisibility, + is_album: false, + album_count: 0 })})`; } catch (err) { console.error('[UPLOAD] YouTube new_item notify failed:', err); @@ -1227,7 +1229,9 @@ export const handleUpload = async (req, res, self) => { tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, is_oc: !!is_oc, slug: itemSlug, - visibility: targetVisibility + visibility: targetVisibility, + is_album: !!is_album, + album_count: is_album ? rawFiles.length : 0 })})`; } catch (err) { console.error('[UPLOAD] new_item notify failed:', err); diff --git a/views/settings.html b/views/settings.html index d11d6c8..1b626ce 100644 --- a/views/settings.html +++ b/views/settings.html @@ -252,94 +252,12 @@ {{ t('settings.enable_bg_blur_hint') }}
-
-
- - 70% -
- - Overlay darkness in Onara mode (lower values allow background canvas to shine through). -
- -
-
- - 5px -
- - Backdrop blur applied behind the Onara media modal. -
- -
-
- - 38% -
- - Control how clearly the background thumbnail grid shines through over the ambient canvas in Onara mode. -
- -
-
- - 100% -
- - Control the visibility and brightness of the ambient background canvas & audio visualizer. -
- -
-
- - 30px -
- - Adjust the ambient blur radius. Higher values = softer, dreamier glow. In GPU CSS mode this goes up to 300px at full resolution. -
- -
-
- - 0.45x -
- - Boost brightness so colors pop. Values < 1 darken, > 1 amplify. -
- -
-
- - Canvas 2D -
- - 0 = Canvas 2D (downsampled, fast)  |  1 = GPU CSS Filter (classic look, full-res compositor blur like older versions)  |  2 = Hybrid (CSS blur + canvas color grading) -
- -
-
- - 1.80x -
- - Boost color saturation of the background. 1.0 = original, higher = more vivid / neon. -
- -
-
- - 1.10x -
- - Contrast punch. Values > 1.0 darken blacks and pop highlights. -
- -
-
- - 1.12x -
- - Zoom the background canvas slightly to hide blurry edge bleeding. 1.12 eliminates edge artifacts at typical blur values. +
+ + Open items in the fullscreen Onara modal instead of navigating to the item page. Fine-tune appearance in the Tuner → Background tab.