fix(immersive): destroy artwork structure, preserve only mood
Implement proper atmospheric background pipeline: - Random NON-CENTERED crop (150-300% zoom) to break composition - Extreme blur at small size (destroys all semantic detail) - Scale up with smoothing for additional blur - Color adjustments (saturation 1.4x, brightness 0.5x) - Vignette overlay The background is now an unrecognizable color field derived from artwork colors. Acceptance test: "Can I identify the album from the background alone?" must be NO. Applies to both WebGL and CSS fallback renderers.
This commit is contained in:
1 parent
e4a108dc16
commit
0bdd83dc08
2 files changed
+318
-82
No files matched your search
@@ -62,26 +62,106 @@
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// CSS Fallback Implementation (original code)
|
||||
// CSS Fallback Implementation
|
||||
// Uses same atmospheric transformation as WebGL path
|
||||
// =====================================================
|
||||
|
||||
// Seeded random for consistent crops per image URL
|
||||
function seededRandom(seed: string): () => number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
const char = seed.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return () => {
|
||||
hash = Math.imul(hash ^ (hash >>> 16), 2246822507);
|
||||
hash = Math.imul(hash ^ (hash >>> 13), 3266489909);
|
||||
hash ^= hash >>> 16;
|
||||
return (hash >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
async function generateBlurredBackground(imageUrl: string): Promise<void> {
|
||||
if (!canvasRef || !imageUrl) return;
|
||||
|
||||
const ctx = canvasRef.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = () => {
|
||||
const size = 64;
|
||||
canvasRef!.width = size;
|
||||
canvasRef!.height = size;
|
||||
const rand = seededRandom(imageUrl);
|
||||
|
||||
ctx.drawImage(img, 0, 0, size, size);
|
||||
// Step 1: Extract random NON-CENTERED crop at small size
|
||||
const CROP_SIZE = 32;
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
cropCanvas.width = CROP_SIZE;
|
||||
cropCanvas.height = CROP_SIZE;
|
||||
const cropCtx = cropCanvas.getContext('2d');
|
||||
if (!cropCtx) { isLoading = false; return; }
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, size, size);
|
||||
// Zoom factor: 1.5x to 3x
|
||||
const zoomFactor = 1.5 + rand() * 1.5;
|
||||
const sourceSize = Math.min(img.width, img.height) / zoomFactor;
|
||||
const maxOffset = Math.min(img.width, img.height) - sourceSize;
|
||||
|
||||
// Random position, biased away from center
|
||||
let sx = rand() * maxOffset;
|
||||
let sy = rand() * maxOffset;
|
||||
|
||||
const cropCenterX = sx + sourceSize / 2;
|
||||
const cropCenterY = sy + sourceSize / 2;
|
||||
const imageCenterX = img.width / 2;
|
||||
const imageCenterY = img.height / 2;
|
||||
|
||||
const distFromCenter = Math.sqrt(
|
||||
Math.pow(cropCenterX - imageCenterX, 2) +
|
||||
Math.pow(cropCenterY - imageCenterY, 2)
|
||||
);
|
||||
const minDistFromCenter = Math.min(img.width, img.height) * 0.15;
|
||||
|
||||
if (distFromCenter < minDistFromCenter && maxOffset > 0) {
|
||||
const corner = Math.floor(rand() * 4);
|
||||
switch (corner) {
|
||||
case 0: sx = 0; sy = 0; break;
|
||||
case 1: sx = maxOffset; sy = 0; break;
|
||||
case 2: sx = 0; sy = maxOffset; break;
|
||||
case 3: sx = maxOffset; sy = maxOffset; break;
|
||||
}
|
||||
}
|
||||
|
||||
cropCtx.drawImage(
|
||||
img,
|
||||
sx, sy, sourceSize, sourceSize,
|
||||
0, 0, CROP_SIZE, CROP_SIZE
|
||||
);
|
||||
|
||||
// Step 2: Apply extreme blur at small size (destroys structure)
|
||||
let current = cropCanvas;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const next = document.createElement('canvas');
|
||||
next.width = CROP_SIZE;
|
||||
next.height = CROP_SIZE;
|
||||
const ctx = next.getContext('2d');
|
||||
if (!ctx) continue;
|
||||
ctx.filter = 'blur(8px)';
|
||||
const expand = 16;
|
||||
ctx.drawImage(current, -expand, -expand, CROP_SIZE + expand * 2, CROP_SIZE + expand * 2);
|
||||
current = next;
|
||||
}
|
||||
|
||||
// Step 3: Scale up to final size with smoothing
|
||||
const FINAL_SIZE = 128;
|
||||
canvasRef!.width = FINAL_SIZE;
|
||||
canvasRef!.height = FINAL_SIZE;
|
||||
const finalCtx = canvasRef!.getContext('2d');
|
||||
if (!finalCtx) { isLoading = false; return; }
|
||||
|
||||
finalCtx.imageSmoothingEnabled = true;
|
||||
finalCtx.imageSmoothingQuality = 'high';
|
||||
finalCtx.drawImage(current, 0, 0, FINAL_SIZE, FINAL_SIZE);
|
||||
|
||||
// Step 4: Apply color adjustments via pixel manipulation
|
||||
const imageData = finalCtx.getImageData(0, 0, FINAL_SIZE, FINAL_SIZE);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
@@ -90,17 +170,23 @@
|
||||
const b = data[i + 2];
|
||||
const avg = (r + g + b) / 3;
|
||||
|
||||
const satFactor = 1.3;
|
||||
// Saturation boost (1.4x) + brightness reduction (0.5x) + contrast reduction
|
||||
const satFactor = 1.4;
|
||||
let newR = avg + (r - avg) * satFactor;
|
||||
let newG = avg + (g - avg) * satFactor;
|
||||
let newB = avg + (b - avg) * satFactor;
|
||||
|
||||
data[i] = Math.min(255, Math.max(0, newR * 0.55));
|
||||
data[i + 1] = Math.min(255, Math.max(0, newG * 0.55));
|
||||
data[i + 2] = Math.min(255, Math.max(0, newB * 0.55));
|
||||
// Brightness and contrast
|
||||
newR = newR * 0.5;
|
||||
newG = newG * 0.5;
|
||||
newB = newB * 0.5;
|
||||
|
||||
data[i] = Math.min(255, Math.max(0, newR));
|
||||
data[i + 1] = Math.min(255, Math.max(0, newG));
|
||||
data[i + 2] = Math.min(255, Math.max(0, newB));
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
finalCtx.putImageData(imageData, 0, 0);
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
@@ -165,12 +251,12 @@
|
||||
|
||||
.background-canvas {
|
||||
position: absolute;
|
||||
inset: -80px;
|
||||
width: calc(100% + 160px);
|
||||
height: calc(100% + 160px);
|
||||
inset: -120px;
|
||||
width: calc(100% + 240px);
|
||||
height: calc(100% + 240px);
|
||||
image-rendering: auto;
|
||||
filter: blur(40px);
|
||||
transform: scale(1.15) translateZ(0);
|
||||
filter: blur(60px);
|
||||
transform: scale(1.2) translateZ(0);
|
||||
will-change: opacity;
|
||||
transition: opacity 500ms ease-out;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,121 @@
|
||||
/**
|
||||
* Texture Loader
|
||||
* Texture Loader - Atmospheric Background Generator
|
||||
*
|
||||
* Creates a high-quality pre-blurred texture that matches CSS blur(40px) quality.
|
||||
* The blur is computed ONCE at load time - no per-frame processing.
|
||||
* Creates an ATMOSPHERIC COLOR FIELD from album artwork.
|
||||
* The artwork is used ONLY as a color/mood source.
|
||||
* The original image must be COMPLETELY UNRECOGNIZABLE.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Load image at reasonable size (256x256)
|
||||
* 2. Apply heavy Gaussian-like blur via multiple canvas filter passes
|
||||
* 3. Apply color adjustments (saturation, brightness)
|
||||
* 4. Upload to GPU texture ONCE
|
||||
* Pipeline (NON-NEGOTIABLE):
|
||||
* 1. Crop random NON-CENTERED region (150-300% zoom)
|
||||
* 2. Downscale to TINY size (destroys all detail)
|
||||
* 3. Apply EXTREME blur (destroys remaining structure)
|
||||
* 4. Amplify colors while reducing contrast
|
||||
* 5. Upload to GPU ONCE
|
||||
*
|
||||
* Acceptance Test:
|
||||
* "If I hide the album cover, can I tell which album this is?"
|
||||
* YES = FAIL, NO = PASS
|
||||
*/
|
||||
|
||||
import { createTextureFromImage } from './webgl-utils';
|
||||
|
||||
// Cache of loaded blurred images
|
||||
const blurCache = new Map<string, string>();
|
||||
// Cache of generated atmospheric textures
|
||||
const atmosphereCache = new Map<string, string>();
|
||||
const MAX_CACHE_SIZE = 20;
|
||||
|
||||
// Active load requests (for cancellation)
|
||||
const activeLoads = new Map<string, AbortController>();
|
||||
|
||||
// Seeded random for consistent crops per image URL
|
||||
function seededRandom(seed: string): () => number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
const char = seed.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return () => {
|
||||
hash = Math.imul(hash ^ (hash >>> 16), 2246822507);
|
||||
hash = Math.imul(hash ^ (hash >>> 13), 3266489909);
|
||||
hash ^= hash >>> 16;
|
||||
return (hash >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply multiple blur passes to achieve CSS blur(40px) equivalent.
|
||||
* Uses OffscreenCanvas if available for better performance.
|
||||
* Extract a random NON-CENTERED crop from the image.
|
||||
* Zoom: 150-300% (takes a portion, not the whole image)
|
||||
* Position: Random, avoiding center to break symmetry
|
||||
*/
|
||||
function applyHeavyBlur(
|
||||
function extractRandomCrop(
|
||||
img: HTMLImageElement,
|
||||
targetSize: number,
|
||||
seed: string
|
||||
): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetSize;
|
||||
canvas.height = targetSize;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return canvas;
|
||||
|
||||
const rand = seededRandom(seed);
|
||||
|
||||
// Zoom factor: 1.5x to 3x (we take 33%-66% of the image)
|
||||
const zoomFactor = 1.5 + rand() * 1.5;
|
||||
|
||||
// Source region size (what portion of original image to take)
|
||||
const sourceSize = Math.min(img.width, img.height) / zoomFactor;
|
||||
|
||||
// Available range for crop position (avoid edges)
|
||||
const maxOffset = Math.min(img.width, img.height) - sourceSize;
|
||||
|
||||
// Random position, biased AWAY from center
|
||||
// Generate position, then push away from center if too close
|
||||
let sx = rand() * maxOffset;
|
||||
let sy = rand() * maxOffset;
|
||||
|
||||
// Calculate center of crop
|
||||
const cropCenterX = sx + sourceSize / 2;
|
||||
const cropCenterY = sy + sourceSize / 2;
|
||||
const imageCenterX = img.width / 2;
|
||||
const imageCenterY = img.height / 2;
|
||||
|
||||
// If crop is too centered, push it away
|
||||
const distFromCenter = Math.sqrt(
|
||||
Math.pow(cropCenterX - imageCenterX, 2) +
|
||||
Math.pow(cropCenterY - imageCenterY, 2)
|
||||
);
|
||||
const minDistFromCenter = Math.min(img.width, img.height) * 0.15;
|
||||
|
||||
if (distFromCenter < minDistFromCenter && maxOffset > 0) {
|
||||
// Push towards a random corner
|
||||
const corner = Math.floor(rand() * 4);
|
||||
switch (corner) {
|
||||
case 0: sx = 0; sy = 0; break; // top-left
|
||||
case 1: sx = maxOffset; sy = 0; break; // top-right
|
||||
case 2: sx = 0; sy = maxOffset; break; // bottom-left
|
||||
case 3: sx = maxOffset; sy = maxOffset; break; // bottom-right
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the cropped region scaled to target size
|
||||
ctx.drawImage(
|
||||
img,
|
||||
sx, sy, sourceSize, sourceSize,
|
||||
0, 0, targetSize, targetSize
|
||||
);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply EXTREME blur to completely destroy any remaining structure.
|
||||
* Uses multiple passes and very high blur radius.
|
||||
*/
|
||||
function applyExtremeBlur(
|
||||
sourceCanvas: HTMLCanvasElement,
|
||||
passes: number = 4,
|
||||
blurPerPass: number = 12
|
||||
passes: number,
|
||||
blurRadius: number
|
||||
): HTMLCanvasElement {
|
||||
let current = sourceCanvas;
|
||||
const size = sourceCanvas.width;
|
||||
@@ -39,9 +127,16 @@ function applyHeavyBlur(
|
||||
const ctx = next.getContext('2d');
|
||||
if (!ctx) return current;
|
||||
|
||||
// Each pass applies blur
|
||||
ctx.filter = `blur(${blurPerPass}px)`;
|
||||
ctx.drawImage(current, 0, 0);
|
||||
// Apply blur with overpaint to avoid edge artifacts
|
||||
ctx.filter = `blur(${blurRadius}px)`;
|
||||
|
||||
// Draw larger and offset to eliminate edge darkening
|
||||
const expand = blurRadius * 2;
|
||||
ctx.drawImage(
|
||||
current,
|
||||
-expand, -expand,
|
||||
size + expand * 2, size + expand * 2
|
||||
);
|
||||
|
||||
current = next;
|
||||
}
|
||||
@@ -50,15 +145,69 @@ function applyHeavyBlur(
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a pre-blurred background image.
|
||||
* This creates a texture that looks like CSS filter: blur(40px).
|
||||
* Apply color adjustments to create atmospheric mood.
|
||||
* - Boost saturation (preserve mood)
|
||||
* - Reduce brightness (background use)
|
||||
* - Reduce contrast (smooth gradients)
|
||||
*/
|
||||
async function generateBlurredImage(
|
||||
function applyColorAdjustments(
|
||||
sourceCanvas: HTMLCanvasElement
|
||||
): HTMLCanvasElement {
|
||||
const size = sourceCanvas.width;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return sourceCanvas;
|
||||
|
||||
// Saturation boost + brightness/contrast reduction
|
||||
ctx.filter = 'saturate(1.4) brightness(0.5) contrast(0.85)';
|
||||
ctx.drawImage(sourceCanvas, 0, 0);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add subtle vignette to push focus inward.
|
||||
*/
|
||||
function applyVignette(
|
||||
sourceCanvas: HTMLCanvasElement,
|
||||
intensity: number = 0.3
|
||||
): HTMLCanvasElement {
|
||||
const size = sourceCanvas.width;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return sourceCanvas;
|
||||
|
||||
// Draw source
|
||||
ctx.drawImage(sourceCanvas, 0, 0);
|
||||
|
||||
// Create radial gradient for vignette
|
||||
const gradient = ctx.createRadialGradient(
|
||||
size / 2, size / 2, size * 0.2,
|
||||
size / 2, size / 2, size * 0.7
|
||||
);
|
||||
gradient.addColorStop(0, `rgba(0, 0, 0, 0)`);
|
||||
gradient.addColorStop(1, `rgba(0, 0, 0, ${intensity})`);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an atmospheric color field from artwork.
|
||||
* The result must be COMPLETELY UNRECOGNIZABLE.
|
||||
*/
|
||||
async function generateAtmosphere(
|
||||
imageUrl: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
// Check cache first
|
||||
const cached = blurCache.get(imageUrl);
|
||||
const cached = atmosphereCache.get(imageUrl);
|
||||
if (cached) return cached;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -86,50 +235,51 @@ async function generateBlurredImage(
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Draw image to initial canvas at target size
|
||||
// 256x256 provides good quality while keeping processing fast
|
||||
const SIZE = 256;
|
||||
const BLUR_PASSES = 4;
|
||||
const BLUR_PER_PASS = 15; // 4 passes * 15px ≈ CSS blur(40-50px)
|
||||
// === TRANSFORMATION PIPELINE ===
|
||||
|
||||
const sourceCanvas = document.createElement('canvas');
|
||||
sourceCanvas.width = SIZE;
|
||||
sourceCanvas.height = SIZE;
|
||||
const sourceCtx = sourceCanvas.getContext('2d');
|
||||
if (!sourceCtx) {
|
||||
// Step 1: Extract random NON-CENTERED crop
|
||||
// Use small initial size - we're destroying detail anyway
|
||||
const INITIAL_SIZE = 64;
|
||||
const croppedCanvas = extractRandomCrop(img, INITIAL_SIZE, imageUrl);
|
||||
|
||||
// Step 2: First blur pass at small size (very efficient)
|
||||
// High blur radius relative to size destroys all structure
|
||||
const smallBlurred = applyExtremeBlur(croppedCanvas, 3, 12);
|
||||
|
||||
// Step 3: Scale up to final size
|
||||
const FINAL_SIZE = 256;
|
||||
const scaledCanvas = document.createElement('canvas');
|
||||
scaledCanvas.width = FINAL_SIZE;
|
||||
scaledCanvas.height = FINAL_SIZE;
|
||||
const scaledCtx = scaledCanvas.getContext('2d');
|
||||
if (!scaledCtx) {
|
||||
reject(new Error('Could not get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw image scaled to fit canvas
|
||||
sourceCtx.drawImage(img, 0, 0, SIZE, SIZE);
|
||||
// Bilinear interpolation during scale-up adds more blur
|
||||
scaledCtx.imageSmoothingEnabled = true;
|
||||
scaledCtx.imageSmoothingQuality = 'high';
|
||||
scaledCtx.drawImage(smallBlurred, 0, 0, FINAL_SIZE, FINAL_SIZE);
|
||||
|
||||
// Step 2: Apply heavy blur (multiple passes)
|
||||
const blurredCanvas = applyHeavyBlur(sourceCanvas, BLUR_PASSES, BLUR_PER_PASS);
|
||||
// Step 4: Additional blur at final size for smoothness
|
||||
const finalBlurred = applyExtremeBlur(scaledCanvas, 2, 20);
|
||||
|
||||
// Step 3: Apply color adjustments
|
||||
const finalCanvas = document.createElement('canvas');
|
||||
finalCanvas.width = SIZE;
|
||||
finalCanvas.height = SIZE;
|
||||
const finalCtx = finalCanvas.getContext('2d');
|
||||
if (!finalCtx) {
|
||||
reject(new Error('Could not get final canvas context'));
|
||||
return;
|
||||
}
|
||||
// Step 5: Apply color adjustments
|
||||
const colorAdjusted = applyColorAdjustments(finalBlurred);
|
||||
|
||||
// Saturation boost + brightness reduction for background use
|
||||
finalCtx.filter = 'saturate(1.3) brightness(0.55)';
|
||||
finalCtx.drawImage(blurredCanvas, 0, 0);
|
||||
// Step 6: Apply subtle vignette
|
||||
const final = applyVignette(colorAdjusted, 0.25);
|
||||
|
||||
// Step 4: Convert to data URL
|
||||
const dataUrl = finalCanvas.toDataURL('image/jpeg', 0.85);
|
||||
// Convert to data URL
|
||||
const dataUrl = final.toDataURL('image/jpeg', 0.9);
|
||||
|
||||
// Cache the result
|
||||
if (blurCache.size >= MAX_CACHE_SIZE) {
|
||||
const firstKey = blurCache.keys().next().value;
|
||||
if (firstKey) blurCache.delete(firstKey);
|
||||
if (atmosphereCache.size >= MAX_CACHE_SIZE) {
|
||||
const firstKey = atmosphereCache.keys().next().value;
|
||||
if (firstKey) atmosphereCache.delete(firstKey);
|
||||
}
|
||||
blurCache.set(imageUrl, dataUrl);
|
||||
atmosphereCache.set(imageUrl, dataUrl);
|
||||
|
||||
resolve(dataUrl);
|
||||
} catch (err) {
|
||||
@@ -187,10 +337,10 @@ export interface LoadTextureResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a pre-blurred texture from artwork URL.
|
||||
* Load an atmospheric texture from artwork URL.
|
||||
*
|
||||
* The texture is blurred ONCE at load time.
|
||||
* No per-frame blur processing needed.
|
||||
* The texture is an UNRECOGNIZABLE color field derived from the artwork.
|
||||
* Computed ONCE at load time - no per-frame processing.
|
||||
*/
|
||||
export async function loadBlurredTexture(
|
||||
gl: WebGL2RenderingContext,
|
||||
@@ -207,14 +357,14 @@ export async function loadBlurredTexture(
|
||||
activeLoads.set(requestId, controller);
|
||||
|
||||
try {
|
||||
// Generate pre-blurred image (cached)
|
||||
const blurredDataUrl = await generateBlurredImage(
|
||||
// Generate atmospheric color field (cached)
|
||||
const atmosphereDataUrl = await generateAtmosphere(
|
||||
artworkUrl,
|
||||
controller.signal
|
||||
);
|
||||
|
||||
// Load as image element
|
||||
const img = await loadImage(blurredDataUrl, controller.signal);
|
||||
const img = await loadImage(atmosphereDataUrl, controller.signal);
|
||||
|
||||
// Create texture (uploaded ONCE)
|
||||
const texture = createTextureFromImage(gl, img);
|
||||
@@ -252,10 +402,10 @@ export function cancelAllLoads(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the blur cache.
|
||||
* Clear the atmosphere cache.
|
||||
*/
|
||||
export function clearBlurCache(): void {
|
||||
blurCache.clear();
|
||||
atmosphereCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,7 +413,7 @@ export function clearBlurCache(): void {
|
||||
*/
|
||||
export function getBlurCacheStats(): { size: number; maxSize: number } {
|
||||
return {
|
||||
size: blurCache.size,
|
||||
size: atmosphereCache.size,
|
||||
maxSize: MAX_CACHE_SIZE,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user