This commit is contained in:
2026-09-29 18:34:51 +02:00
parent 6c813143eb
commit 40ba687970
5 changed files with 260 additions and 63 deletions
+133 -38
View File
@@ -3745,6 +3745,7 @@ window.cancelAnimFrame = (function () {
const newYtSrc = ytId ? `https://www.youtube.com/embed/${ytId}?enablejsapi=1` : newSrc;
if (ytIframe.src !== newYtSrc) ytIframe.src = newYtSrc;
ytIframe.style.display = 'block';
if (window.stopVisualizer) window.stopVisualizer();
} else if (mime.startsWith('video/')) {
container.classList.remove('is-youtube-active');
container.classList.add('is-video-active');
@@ -3770,6 +3771,7 @@ window.cancelAnimFrame = (function () {
initAlbumVideoV0ck();
video = videoEl;
if (window.stopVisualizer) window.stopVisualizer();
if (window.initBackground) window.initBackground();
if (item.size && videoWrapper) {
@@ -3943,6 +3945,8 @@ window.cancelAnimFrame = (function () {
imgEl.classList.add('album-img-fade');
}
video = null;
if (window.stopVisualizer) window.stopVisualizer();
if (window.initBackground) window.initBackground();
}
if (linkEl && !mime.startsWith('video/')) {
@@ -5275,7 +5279,7 @@ window.cancelAnimFrame = (function () {
}
if (!elem) {
const albumAudio = document.getElementById('f0ck-album-audio');
if (albumAudio && albumAudio.src) {
if (albumAudio && albumAudio.src && !isInactiveAlbumAudio(albumAudio)) {
elem = albumAudio;
}
}
@@ -5528,6 +5532,16 @@ window.cancelAnimFrame = (function () {
const itemId = window.getCurrentItemId();
const isDrawable = elem && elem.tagName === 'IMG';
// Album image slides: the /t/ thumb belongs to the whole album, so draw the slide itself
if (isDrawable && elem.closest('.album-gallery-container')) {
if (elem.complete && elem.naturalWidth > 0) {
crossfadeToSource(elem);
} else {
elem.onload = () => crossfadeToSource(elem);
}
return;
}
if (itemId) {
if (document.body.classList.contains('onara-modal-open')) {
const onaraThumbImg = document.querySelector('.posts > a.thumb.onara-active img, .posts > a.thumb:focus img');
@@ -8825,6 +8839,20 @@ window.cancelAnimFrame = (function () {
}
};
// Stop the audio visualizer loop so it no longer paints into #bg
window.stopVisualizer = () => {
if (visualizerRafId) { window.cancelAnimFrame(visualizerRafId); visualizerRafId = null; }
const bgCanvas = document.getElementById('bg');
if (bgCanvas) bgCanvas.style.filter = '';
};
// The album template always contains a hidden <audio>; it only counts while an audio slide is shown
const isInactiveAlbumAudio = (el) => {
if (!el || el.id !== 'f0ck-album-audio') return false;
const albumContainer = el.closest('.album-gallery-container');
return !albumContainer || !albumContainer.classList.contains('is-audio-active');
};
window.initVisualizer = (targetAudio) => {
let audioElement = targetAudio;
if (!audioElement || !audioElement.isConnected) {
@@ -8847,6 +8875,11 @@ window.cancelAnimFrame = (function () {
}
}
}
if (isInactiveAlbumAudio(audioElement)) {
// Album is showing an image/video slide: the visualizer must not take over #bg
window.stopVisualizer();
return;
}
if (audioElement) {
if (!audioElement.crossOrigin) {
audioElement.crossOrigin = 'anonymous';
@@ -17426,41 +17459,77 @@ window.cancelAnimFrame = (function () {
// At ≤599px the sidebar is a pure overlay — never touch wrapper padding
const animateWrappers = window.innerWidth > 599;
// Smoothness: the sidebar and #bg canvas animate via CSS transitions (compositor, minimal layout).
// The content wrappers and Onara modal change width ONCE instead of every frame: before the slide
// when opening, after it when closing. The strip the sidebar travels through is therefore always
// empty background, so no thumbnails reflow behind it; the single reflow is masked with an opacity fade.
const DURATION = 320;
const EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
// Smoothness: the sidebar, #bg canvas, drag pill, right-anchored bars, the Onara modal and the item
// view's grid all move together, frame by frame, with one slow curtain-like easing (same feel as the
// legacy comment slide). Only the thumbnail-grid wrappers (index pages) still change width ONCE
// (before the slide when opening, after it when closing): their thumbnails would re-wrap into
// different rows mid-slide, so that single reflow is masked with an opacity fade instead.
const DURATION = 600;
const EASING = 'cubic-bezier(0.45, 0, 0.2, 1)';
const glide = DURATION + 'ms ' + EASING;
const wrappers = getSidebarWrappers();
const onaraModal = document.getElementById('onara-modal');
const bgCanvas = document.getElementById('bg');
const isEdgeWrapper = w => w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar') || w.classList.contains('messages-convo-page');
// Elements positioned by `right`: glide to the new edge together with the sidebar (both directions)
const moveEdges = (open) => {
if (!animateWrappers) return;
const val = open ? sidebarWidth + 'px' : '0px';
const targets = wrappers ? Array.from(wrappers).filter(isEdgeWrapper) : [];
if (onaraModal) targets.push(onaraModal);
targets.forEach(el => {
// Pin the current value first so the transition has a defined start
el.style.setProperty('transition', 'none', 'important');
el.style.setProperty('right', getComputedStyle(el).right, 'important');
void el.offsetWidth;
el.style.setProperty('transition', 'right ' + glide, 'important');
el.style.setProperty('right', val, 'important');
});
};
// Padding-based wrappers (thumbnail grids): one-time reflow, softened
const applyLayout = (open) => {
// Let overlays that mirror the content area (page modal) resize at exactly the same moment
window.dispatchEvent(new CustomEvent('f0ck:sidebar-right-layout', { detail: { open, width: sidebarWidth, overlay: !animateWrappers } }));
if (!animateWrappers) return;
wrappers.forEach(w => {
const isRightEdge = w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar');
const isMessages = w.classList.contains('messages-convo-page');
const prop = (isRightEdge || isMessages) ? 'right' : 'padding-right';
if (isEdgeWrapper(w)) return;
w.style.setProperty('transition', 'none', 'important');
w.style.setProperty(prop, open ? sidebarWidth + 'px' : '0px', 'important');
});
if (onaraModal) {
onaraModal.style.setProperty('transition', 'none', 'important');
onaraModal.style.setProperty('right', open ? sidebarWidth + 'px' : '0px', 'important');
}
// #bg canvas is handled separately below — it transitions smoothly alongside the sidebar
// Soften the one-time reflow
wrappers.forEach(w => {
if (w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar')) return;
w.style.setProperty('padding-right', open ? sidebarWidth + 'px' : '0px', 'important');
if (typeof w.animate === 'function') w.animate([{ opacity: 0.55 }, { opacity: 1 }], { duration: 220, easing: 'ease-out' });
});
if (typeof window.redrawBgCanvas === 'function') requestAnimationFrame(() => window.redrawBgCanvas());
};
// Opening: make room first, then slide in over empty space
// Item view: animate the grid's sidebar track so media and comments glide with the sidebar instead
// of jumping when the class flips at the end. The target comes from the current track sizes
// (last track = sidebar column, the one before it = flexible content column) rather than from
// flipping the class early, which would kick off unrelated CSS transitions.
let gridAnim = null;
const itemLayout = animateWrappers && !document.body.classList.contains('onara-modal-open')
? document.querySelector('.item-layout-container') : null;
if (itemLayout && typeof itemLayout.animate === 'function') {
const from = getComputedStyle(itemLayout).gridTemplateColumns;
const tracks = from && from !== 'none' ? from.trim().split(/\s+/) : [];
const px = tracks.map(t => (/^-?[\d.]+px$/.test(t) ? parseFloat(t) : NaN));
if (tracks.length >= 2 && px.every(n => !isNaN(n))) {
const last = px.length - 1;
const newLast = isHidden ? sidebarWidth : 0;
if (Math.abs(px[last] - newLast) > 0.5) {
const to = px.slice();
to[last - 1] = Math.max(0, px[last - 1] + (px[last] - newLast));
to[last] = newLast;
itemLayout.style.setProperty('transition', 'none', 'important');
gridAnim = itemLayout.animate(
[{ gridTemplateColumns: px.map(n => n + 'px').join(' ') }, { gridTemplateColumns: to.map(n => n + 'px').join(' ') }],
{ duration: DURATION, easing: EASING, fill: 'forwards' }
);
}
}
}
moveEdges(isHidden);
// Opening: make room in thumbnail grids first, then slide in over empty space
if (isHidden) applyLayout(true);
// The drag-zone pill follows the sidebar edge (small fixed element: cheap to animate)
@@ -17477,22 +17546,17 @@ window.cancelAnimFrame = (function () {
edgeZone.style.pointerEvents = 'all';
}
// Commit the starting width for #bg so the transition has a defined origin
if (bgCanvas && animateWrappers) {
const bgStartWidth = isHidden ? '100vw' : `calc(100vw - ${sidebarWidth}px)`;
bgCanvas.style.setProperty('width', bgStartWidth, 'important');
}
sidebar.style.willChange = 'transform';
void sidebar.offsetWidth; // commit the start state before transitions begin
sidebar.style.setProperty('transition', `transform ${DURATION}ms ${EASING}`, 'important');
sidebar.style.transform = `translateX(${targetTranslate}px)`;
// #bg canvas width transitions in sync with the sidebar slide (position:fixed, no layout cost)
// #bg canvas: same size in both states, it only shifts to stay centred under the content. Moving it
// (transform, GPU) instead of resizing it means no stretched frame mid-slide and no redraw at the end.
if (bgCanvas && animateWrappers) {
const bgTargetWidth = isHidden ? `calc(100vw - ${sidebarWidth}px)` : '100vw';
bgCanvas.style.setProperty('transition', `width ${DURATION}ms ${EASING}`, 'important');
bgCanvas.style.setProperty('width', bgTargetWidth, 'important');
const bgShift = isHidden ? -sidebarWidth / 2 : 0;
bgCanvas.style.setProperty('transition', `transform ${DURATION}ms ${EASING}`, 'important');
bgCanvas.style.setProperty('transform', `translate3d(${bgShift}px, 0, 0) scale(var(--bg-canvas-zoom, 1.38))`, 'important');
}
let settled = false;
@@ -17522,16 +17586,16 @@ window.cancelAnimFrame = (function () {
}
if (bgCanvas) {
bgCanvas.style.removeProperty('transition');
bgCanvas.style.removeProperty('width');
bgCanvas.style.removeProperty('transform');
}
wrappers.forEach(w => {
w.style.removeProperty('transition');
w.style.removeProperty('padding-right');
w.style.removeProperty('right');
});
if (typeof window.redrawBgCanvas === 'function') {
window.redrawBgCanvas();
}
// The class now yields the columns the animation ended on: hand back to the stylesheet
if (gridAnim) gridAnim.cancel();
if (itemLayout) itemLayout.style.removeProperty('transition');
});
};
@@ -22685,6 +22749,12 @@ document.addEventListener('click', (e) => {
// Trackpad sideways swipes (deltaX dominant) are left to the browser.
(() => {
const WHEEL_X_SEL = '.f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, #settings-quicknav';
const WHEEL_X_SPEED = 0.45; // fraction of the wheel delta applied per notch
const WHEEL_X_EASE = 0.12; // share of the remaining distance covered per frame
const stopGlide = (bar) => {
if (bar._wheelRaf) cancelAnimationFrame(bar._wheelRaf);
bar._wheelRaf = null;
};
document.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.defaultPrevented) return; // ctrl+wheel = zoom
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
@@ -22695,7 +22765,9 @@ document.addEventListener('click', (e) => {
const dir = Math.sign(e.deltaY);
// The wheel stays with the bar while the pointer is on it (no page scroll)
e.preventDefault();
if ((dir < 0 && bar.scrollLeft <= 0) || (dir > 0 && bar.scrollLeft >= max - 1)) {
// While a glide is running, measure against where it is heading, not where it currently is
const pos = bar._wheelRaf ? bar._wheelTarget : bar.scrollLeft;
if ((dir < 0 && pos <= 0) || (dir > 0 && pos >= max - 1)) {
// At the end: short rubber-band stretch past the edge, then ease back. A running stretch is not
// restarted, so a continuous wheel spin reads as one bounce instead of a jitter.
const items = Array.from(bar.children).filter(el => typeof el.animate === 'function' && el.offsetParent !== null);
@@ -22722,7 +22794,30 @@ document.addEventListener('click', (e) => {
}
// deltaMode 1 = lines (Firefox mouse wheels), 2 = pages
const amount = Math.abs(e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * bar.clientWidth : e.deltaY);
bar.scrollLeft = Math.max(0, Math.min(max, bar.scrollLeft + dir * amount));
// Slower than a raw wheel step, and eased: each notch moves the target, the bar glides after it
bar._wheelTarget = Math.max(0, Math.min(max, pos + dir * amount * WHEEL_X_SPEED));
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
bar.scrollLeft = bar._wheelTarget;
return;
}
if (!bar._wheelRaf) {
if (!bar._wheelCancelBound) {
// Grabbing the bar (touch/pen/mouse) hands control back to the user
bar.addEventListener('pointerdown', () => stopGlide(bar), { passive: true });
bar._wheelCancelBound = true;
}
const step = () => {
const diff = bar._wheelTarget - bar.scrollLeft;
if (Math.abs(diff) < 0.5) {
bar.scrollLeft = bar._wheelTarget;
bar._wheelRaf = null;
return;
}
bar.scrollLeft += diff * WHEEL_X_EASE;
bar._wheelRaf = requestAnimationFrame(step);
};
bar._wheelRaf = requestAnimationFrame(step);
}
}, { passive: false });
})();