Files
f0ckm/public/s/js/flash_yank.js
2026-04-25 19:51:52 +02:00

724 lines
26 KiB
JavaScript

(function () {
'use strict';
const STORAGE_KEY = 'w0bmFlashFilterSettings_v1';
const DEFAULT_SETTINGS = {
yank: 60, // 0..100 master "yank"
enabled: false,
advanced: false, // if true, use individual params instead of yank mapping
enableResolution: true, // per-axis degradation toggles (advanced only)
enableFps: true,
enablePalette: true,
internalWidth: 320,
fps: 12,
paletteLevels: 4
};
let settings = loadSettings();
let currentConfig = computeConfig();
let chanLUT = buildChannelLUT(currentConfig.paletteLevels);
let currentController = null;
let hotkeyAttached = false;
let ui = null; // { panel, slider, yankValue, info, toggle }
const isMobile = /Mobi/i.test(navigator.userAgent);
function isItemPage() {
const path = window.location.pathname;
// Strictly match item pages (e.g., /123, /user/name/123) and exclude grids/specials
const isItem = (path.match(/^\/\d+/) || path.split('/').some(s => /^\d+$/.test(s))) && !path.match(/\/p\//);
const isForbidden = path === '/upload' || path.startsWith('/admin') || path.startsWith('/mod');
return isItem && !isForbidden;
}
// ---------- Settings / Config ----------
function loadSettings() {
try {
const val = localStorage.getItem(STORAGE_KEY);
if (val) {
const obj = JSON.parse(val);
return Object.assign({}, DEFAULT_SETTINGS, obj);
}
} catch (e) {
console.error('Failed to load settings from localStorage', e);
}
return Object.assign({}, DEFAULT_SETTINGS);
}
function saveSettings() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch (e) {
console.error('Failed to save settings to localStorage', e);
}
}
// Yank 0..100 => config
// Higher yank = harsher: lower res, lower FPS, fewer colors
function computeConfigFromYank(yank) {
const t = Math.min(1, Math.max(0, yank / 100));
// Match the ranges used by the advanced sliders:
// Resolution: 160..800, FPS: 8..30, Palette levels: 2..12
const widthMax = 800, widthMin = 160;
const fpsMax = 30, fpsMin = 8;
const levelsMax = 12, levelsMin = 2; // per channel
const width = Math.round(widthMax - (widthMax - widthMin) * t);
const height = Math.round(width * 9 / 16);
const fps = Math.round(fpsMax - (fpsMax - fpsMin) * t);
const paletteLevels = Math.round(levelsMax - (levelsMax - levelsMin) * t);
return {
internalWidth: width,
internalHeight: null, // Will be calculated dynamically based on aspect ratio
fps,
paletteLevels
};
}
// Update advanced parameters from current yank value
function updateAdvancedFromYank() {
const cfg = computeConfigFromYank(settings.yank);
settings.internalWidth = cfg.internalWidth;
settings.fps = cfg.fps;
settings.paletteLevels = cfg.paletteLevels;
}
// Derive an approximate yank from current advanced sliders (0..100)
function updateYankFromAdvanced() {
const widthMax = 800, widthMin = 160;
const fpsMax = 30, fpsMin = 8;
const levelsMax = 12, levelsMin = 2;
const tWidth = (widthMax - settings.internalWidth) / (widthMax - widthMin);
const tFps = (fpsMax - settings.fps) / (fpsMax - fpsMin);
const tPalette = (levelsMax - settings.paletteLevels) / (levelsMax - levelsMin);
let t = (tWidth + tFps + tPalette) / 3;
t = Math.min(1, Math.max(0, t));
settings.yank = Math.round(t * 100);
}
// Combine yank mapping with optional advanced overrides
function computeConfig() {
if (settings.advanced) {
const w = clamp(settings.internalWidth || DEFAULT_SETTINGS.internalWidth, 160, 800);
const fps = clamp(settings.fps || DEFAULT_SETTINGS.fps, 8, 30);
const pl = clamp(settings.paletteLevels || DEFAULT_SETTINGS.paletteLevels, 2, 12);
settings.internalWidth = w;
settings.fps = fps;
settings.paletteLevels = pl;
return {
internalWidth: w,
internalHeight: null, // Calculated dynamically
fps,
paletteLevels: pl
};
}
const cfg = computeConfigFromYank(settings.yank);
// keep advanced values in sync so toggling advanced inherits current feel
settings.internalWidth = cfg.internalWidth;
settings.fps = cfg.fps;
settings.paletteLevels = cfg.paletteLevels;
return cfg;
}
function clamp(v, min, max) {
v = Number(v);
if (isNaN(v)) return min;
return Math.min(max, Math.max(min, v));
}
function applySettingsToRuntime() {
currentConfig = computeConfig();
chanLUT = buildChannelLUT(currentConfig.paletteLevels);
if (currentController && currentController.onConfigChanged) {
currentController.onConfigChanged();
}
updateUIFromSettings();
}
// ---------- Palette / Image processing ----------
function buildChannelLUT(levels) {
const lut = new Uint8Array(256);
if (levels <= 1) {
lut.fill(0);
return lut;
}
const step = 255 / (levels - 1);
for (let i = 0; i < 256; i++) {
const idx = Math.round(i / step);
lut[i] = Math.round(idx * step);
}
return lut;
}
function applyPalette(imageData, lut) {
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = lut[data[i]];
data[i + 1] = lut[data[i + 1]];
data[i + 2] = lut[data[i + 2]];
// alpha unchanged
}
}
// ---------- Video controller ----------
function createCanvasForVideo(video) {
// Ensure parent is relative for absolute positioning of canvas
const style = window.getComputedStyle(video.parentNode);
if (style.position === 'static') {
video.parentNode.style.position = 'relative';
}
const canvas = document.createElement('canvas');
canvas.style.display = 'block';
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.zIndex = '1'; // Lower z-index so controls can sit on top
canvas.style.pointerEvents = 'none'; // Let clicks pass through to controls
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.backgroundColor = 'transparent';
canvas.style.imageRendering = 'pixelated';
canvas.style.objectFit = 'contain';
video.parentNode.insertBefore(canvas, video.nextSibling);
return canvas;
}
function makeController(video) {
const canvas = createCanvasForVideo(video);
const ctx = canvas.getContext('2d', { willReadFrequently: true });
let enabled = false;
let intervalId = null;
function applyConfigToCanvas() {
const rect = video.getBoundingClientRect();
const displayWidth = rect.width || video.clientWidth || video.width || 640;
const displayHeight = rect.height || video.clientHeight || video.height || 360;
let internalWidth = currentConfig.internalWidth;
let internalHeight;
// Calculate aspect ratio
const videoWidth = video.videoWidth || 640;
const videoHeight = video.videoHeight || 360;
const aspectRatio = videoHeight / videoWidth;
// If advanced resolution scaling is disabled, match display size for best quality
if (settings.advanced && !settings.enableResolution) {
internalWidth = Math.round(displayWidth);
internalHeight = Math.round(displayHeight);
} else {
// Calculate height based on fixed width and aspect ratio
internalHeight = Math.round(internalWidth * aspectRatio);
}
canvas.width = internalWidth;
canvas.height = internalHeight;
canvas.width = internalWidth;
canvas.height = internalHeight;
// The canvas fills the container (which is sized by the hidden video)
canvas.style.width = '100%';
canvas.style.height = '100%';
canvas.style.maxWidth = '';
canvas.style.aspectRatio = '';
}
applyConfigToCanvas();
function drawFrame() {
if (!enabled || video.paused || video.ended) return;
try {
const w = canvas.width;
const h = canvas.height;
ctx.drawImage(video, 0, 0, w, h);
// If advanced palette reduction is disabled, skip quantization
if (!(settings.advanced && !settings.enablePalette)) {
const frame = ctx.getImageData(0, 0, w, h);
applyPalette(frame, chanLUT);
ctx.putImageData(frame, 0, 0);
}
} catch (e) {
// ignore
}
}
function startLoop() {
if (intervalId || !enabled) return;
const effectiveFps = (settings.advanced && !settings.enableFps) ? 60 : currentConfig.fps;
intervalId = setInterval(drawFrame, 1000 / effectiveFps);
}
function stopLoop() {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
}
function enable() {
if (enabled) return;
enabled = true;
canvas.style.display = 'block';
video.style.visibility = 'hidden'; // Keep layout space!
if (!video.paused && !video.ended) startLoop();
}
function disable() {
if (!enabled) return;
enabled = false;
canvas.style.display = 'none';
video.style.visibility = '';
stopLoop();
}
function toggle() {
if (enabled) disable();
else enable();
}
function isEnabled() {
return enabled;
}
function onConfigChanged() {
const wasEnabled = enabled;
stopLoop();
applyConfigToCanvas();
if (wasEnabled) {
startLoop();
}
}
function destroy() {
disable();
canvas.remove();
video.removeEventListener('play', startLoop);
video.removeEventListener('pause', stopLoop);
video.removeEventListener('ended', stopLoop);
}
video.addEventListener('play', startLoop);
video.addEventListener('pause', stopLoop);
video.addEventListener('ended', stopLoop);
video.addEventListener('loadedmetadata', applyConfigToCanvas); // Recalculate when metadata allows
return { enable, disable, toggle, isEnabled, destroy, onConfigChanged };
}
function setupVideo(video) {
if (!video) return;
if (!isItemPage()) {
if (ui) ui.wrapper.style.display = 'none';
return;
}
// Prioritize the main item player (id="my-video" or class "viewer")
const isPrimary = video.id === 'my-video' || video.classList.contains('viewer') || video.classList.contains('v0ck_video');
if (video.dataset.flashFilterAttached === '1') {
// If already attached, ensure its currentController is restored if it's the primary one
if (isPrimary && !currentController) {
currentController = video.__flashFilterController;
}
return;
}
video.dataset.flashFilterAttached = '1';
const controller = makeController(video);
video.__flashFilterController = controller;
// If this is the primary video, or we don't have one yet, set as current
if (isPrimary || !currentController) {
currentController = controller;
}
// Always sync with global setting on creation
if (settings.enabled) {
controller.enable();
} else {
controller.disable();
}
// Small delay to ensure v0ck has injected its controls
setTimeout(() => {
if (ui) updateUIFromSettings();
}, 50);
if (ui && isPrimary) {
ui.wrapper.style.display = 'block';
}
}
function scanExistingVideos() {
document.querySelectorAll('video').forEach(setupVideo);
}
function startObserver() {
if (!document.body) return;
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
if (node.tagName === 'VIDEO') {
setupVideo(node);
} else if (node.querySelectorAll) {
node.querySelectorAll('video').forEach(setupVideo);
}
});
m.removedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
const vids = [];
if (node.tagName === 'VIDEO') {
vids.push(node);
}
if (node.querySelectorAll) {
node.querySelectorAll('video').forEach(v => vids.push(v));
}
vids.forEach((v) => {
const c = v.__flashFilterController;
if (c) {
c.destroy();
if (currentController === c) {
currentController = null;
}
}
});
if (ui && document.querySelectorAll('video').length === 0) {
ui.wrapper.style.display = 'none';
}
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// ---------- UI Panel ----------
function createOptionsUI() {
if (ui || !document.body) return;
const wrapper = document.createElement('div');
wrapper.style.position = 'fixed';
wrapper.style.bottom = '10px';
wrapper.style.right = '10px';
wrapper.style.zIndex = '99999';
wrapper.style.fontFamily = 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
wrapper.style.fontSize = '12px';
wrapper.style.color = '#fff';
wrapper.style.display = 'none'; // Initially hidden
wrapper.id = 'flash-yank-ui';
const floatingBadge = document.createElement('div');
floatingBadge.id = 'w0bm-floating-swf';
floatingBadge.textContent = 'SWF';
floatingBadge.style.background = 'rgba(0,0,0,0.9)';
floatingBadge.style.padding = '2px 8px';
floatingBadge.style.borderRadius = '3px';
floatingBadge.style.cursor = 'pointer';
floatingBadge.style.fontWeight = 'bold';
floatingBadge.style.letterSpacing = '0.08em';
const panel = document.createElement('div');
panel.style.position = 'fixed';
panel.style.right = '10px';
panel.style.bottom = '22px';
panel.style.background = 'rgba(0,0,0,0.9)';
panel.style.color = '#fff';
panel.style.padding = '6px 10px';
panel.style.fontSize = '12px';
panel.style.borderRadius = '4px';
panel.style.maxWidth = '320px';
panel.style.minWidth = '240px';
panel.style.maxHeight = 'calc(100vh - 40px)'; // Prevent exceeding screen height
panel.style.overflowY = 'auto'; // Make scrollable
panel.style.boxSizing = 'border-box';
panel.style.boxShadow = '0 0 6px rgba(0,0,0,0.7)';
panel.style.display = 'none';
panel.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div id="w0bm-title" style="font-weight:bold; cursor:pointer;">Flash Yank</div>
<div id="w0bm-close" style="cursor:pointer; font-size:16px; padding: 0 4px;">&times;</div>
</div>
<label style="font-size:11px; display:block; margin-bottom:2px;">
Yank: <span id="w0bm-yank-value"></span>
</label>
<input id="w0bm-yank-slider" type="range" min="0" max="100" step="1" style="width:100%;">
<div id="w0bm-yank-info" style="font-size:10px; margin-top:8px; opacity: 0.8;"></div>
`;
wrapper.appendChild(panel);
wrapper.appendChild(floatingBadge);
document.body.appendChild(wrapper);
let panelVisible = false;
const HOVER_MARGIN = 50; // px around panel before closing
let activePlayer = null;
function showPanel(trigger) {
if (panelVisible) return;
panelVisible = true;
panel.style.display = 'block';
activePlayer = trigger.closest('.v0ck');
if (activePlayer) activePlayer.classList.add('v0ck_swf_active');
}
function hidePanel() {
if (!panelVisible) return;
panelVisible = false;
panel.style.display = 'none';
if (activePlayer) {
activePlayer.classList.remove('v0ck_swf_active');
activePlayer = null;
}
}
function handleBadgeHover(target) {
if (target.id === 'toggleswf' || target === floatingBadge) {
// Anchor to trigger element (shared logic for mobile/desktop)
const rect = target.getBoundingClientRect();
panel.style.bottom = (window.innerHeight - rect.top + 10) + 'px';
panel.style.right = (window.innerWidth - rect.right) + 'px';
panel.style.transform = 'none';
panel.style.maxWidth = isMobile ? '90vw' : '320px';
showPanel(target);
}
}
document.addEventListener('mouseover', (e) => {
const target = e.target.closest('#toggleswf') || (e.target === floatingBadge ? floatingBadge : null);
if (target) {
handleBadgeHover(target);
}
});
document.addEventListener('mousemove', (e) => {
if (!panelVisible || isMobile) return; // Don't hide by mousemove on mobile
const rect = panel.getBoundingClientRect();
const left = rect.left - HOVER_MARGIN;
const right = rect.right + HOVER_MARGIN;
const top = rect.top - HOVER_MARGIN;
const bottom = rect.bottom + HOVER_MARGIN;
if (e.clientX < left || e.clientX > right || e.clientY < top || e.clientY > bottom) {
hidePanel();
}
});
const title = panel.querySelector('#w0bm-title');
const slider = panel.querySelector('#w0bm-yank-slider');
const yankValue = panel.querySelector('#w0bm-yank-value');
const info = panel.querySelector('#w0bm-yank-info');
ui = {
wrapper,
floatingBadge,
panel,
title,
slider,
yankValue,
info
};
slider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10);
settings.yank = isNaN(val) ? 0 : Math.min(100, Math.max(0, val));
// Yank drives the underlying advanced parameters
updateAdvancedFromYank();
saveSettings();
applySettingsToRuntime();
});
function toggleEnabledFromUI(targetController) {
settings.enabled = !settings.enabled;
saveSettings();
// If a specific controller was the target (e.g. clicked inside a player), use it.
// Otherwise use the global currentController (main player).
const controller = targetController || currentController;
if (controller) {
if (settings.enabled) controller.enable();
else controller.disable();
}
// For global consistency, if settings.enabled changed, we might want to toggle ALL?
// But per user request, we focus on the item player.
// If there's another video that isn't the currentController, it won't toggle here,
// but the hotkey and UI rely on currentController.
updateUIFromSettings();
}
// Click SWF badge or title to toggle filter enabled/disabled
document.addEventListener('click', (e) => {
const swfBtn = e.target.closest('#toggleswf');
const isBadge = swfBtn || e.target === floatingBadge;
const isTitle = e.target === title;
if (isBadge || isTitle) {
// If clicked a button inside a player, try to get THAT player's controller
let targetCtrl = null;
if (swfBtn) {
const player = swfBtn.closest('.v0ck');
const vid = player ? player.querySelector('video') : null;
if (vid && vid.__flashFilterController) {
targetCtrl = vid.__flashFilterController;
}
}
toggleEnabledFromUI(targetCtrl);
// On mobile, explicitly show panel on click/tap
if (isMobile) {
handleBadgeHover(swfBtn || floatingBadge);
}
}
});
const closeBtn = panel.querySelector('#w0bm-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
hidePanel();
});
}
// Close Flash Yank panel when the main settings menu is closed
document.addEventListener('v0ck_settings_closed', () => {
hidePanel();
});
updateUIFromSettings();
}
function updateUIFromSettings() {
if (!ui) return;
const isItem = isItemPage();
const hasVideos = document.querySelectorAll('video').length > 0;
if (!isItem || !hasVideos) {
ui.wrapper.style.display = 'none';
return;
}
ui.slider.value = settings.yank;
ui.yankValue.textContent = settings.yank + '%';
const approxColors = Math.pow(currentConfig.paletteLevels, 3);
ui.info.textContent = currentConfig.internalWidth + 'px width, ' +
currentConfig.fps + 'fps, ~' + approxColors + ' colors';
// Enable/disable controls based on enabled state
const isEnabled = !!settings.enabled;
ui.slider.disabled = !isEnabled;
// Visual state: strike-through when disabled
const swfButtons = Array.from(document.querySelectorAll('.v0ck_menu_item')).filter(b => b.textContent.trim() === 'SWF');
// Handle floating badge visibility
ui.floatingBadge.style.display = swfButtons.length > 0 ? 'none' : 'block';
// Style both (if they exist)
[ui.floatingBadge, ...swfButtons].forEach(b => {
if (!b) return;
b.style.textDecoration = isEnabled ? 'none' : 'line-through';
b.style.opacity = isEnabled ? '1' : '0.6';
if (b.classList.contains('v0ck_menu_item')) {
b.style.color = isEnabled ? 'var(--accent, #9f0)' : '#fff';
b.style.fontWeight = isEnabled ? 'bold' : 'normal';
}
});
}
// ---------- Hotkey ----------
function attachHotkey() {
if (hotkeyAttached) return;
hotkeyAttached = true;
document.addEventListener('keydown', (e) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || !isItemPage()) return;
if (e.key.toLowerCase() !== 's') return;
// Ignore when typing in an input, textarea, or contenteditable
const tag = document.activeElement?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable) return;
if (!currentController) return;
settings.enabled = !settings.enabled;
if (settings.enabled) currentController.enable();
else currentController.disable();
saveSettings();
updateUIFromSettings();
if (typeof window.flashMessage === 'function') {
window.flashMessage(`Flash Yank ${settings.enabled ? 'enabled' : 'disabled'}`, 2000, settings.enabled ? 'success' : 'error');
}
});
}
// ---------- Bootstrapping ----------
function onReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
} else {
fn();
}
}
onReady(() => {
// firefox mobile check
const isFirefoxMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent) && /firefox/i.test(navigator.userAgent);
if (isFirefoxMobile) {
console.log("Firefox Mobile detected, disabling Flash Yank script.");
return;
}
applySettingsToRuntime();
createOptionsUI();
scanExistingVideos();
startObserver();
attachHotkey();
// Path change handling for AJAX transitions
const handlePathChange = () => {
// Reset currentController on path change to force re-discovery
currentController = null;
if (!isItemPage()) {
if (ui) ui.wrapper.style.display = 'none';
} else {
// Scan after a short delay to allow DOM/v0ck to settle
setTimeout(() => {
scanExistingVideos();
updateUIFromSettings();
}, 100);
}
};
window.addEventListener('popstate', handlePathChange);
document.addEventListener('f0ck:contentLoaded', handlePathChange);
});
})();