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.