467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
export interface ColorPalette {
|
|
vibrant: string;
|
|
darkVibrant: string;
|
|
lightVibrant: string;
|
|
muted: string;
|
|
darkMuted: string;
|
|
lightMuted: string;
|
|
}
|
|
|
|
/**
|
|
* Extract dominant colors from an image using Canvas API
|
|
*/
|
|
function extractColorsFromImage(imageUrl: string): Promise<ColorPalette> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
|
|
// ALWAYS set crossOrigin for images that need color extraction
|
|
// This is required for Canvas.getImageData() to work
|
|
// The backend must send Access-Control-Allow-Origin header
|
|
img.crossOrigin = "anonymous";
|
|
|
|
img.onload = () => {
|
|
try {
|
|
// Create canvas and get context
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) {
|
|
throw new Error("Could not get canvas context");
|
|
}
|
|
|
|
// Scale down for performance
|
|
const scaleFactor = 0.1;
|
|
canvas.width = img.width * scaleFactor;
|
|
canvas.height = img.height * scaleFactor;
|
|
|
|
// Draw image
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
|
|
// Get image data
|
|
const imageData = ctx.getImageData(
|
|
0,
|
|
0,
|
|
canvas.width,
|
|
canvas.height
|
|
);
|
|
const pixels = imageData.data;
|
|
|
|
// Count color frequencies, prioritizing saturated colors
|
|
const colorMap = new Map<
|
|
string,
|
|
{ count: number; saturation: number }
|
|
>();
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
const r = pixels[i];
|
|
const g = pixels[i + 1];
|
|
const b = pixels[i + 2];
|
|
const a = pixels[i + 3];
|
|
|
|
// Skip transparent pixels
|
|
if (a < 125) continue;
|
|
|
|
// Skip extremely dark and extremely light pixels
|
|
const brightness = (r + g + b) / 3;
|
|
if (brightness < 15 || brightness > 240) continue;
|
|
|
|
// Calculate saturation to favor colorful pixels
|
|
const saturation = getSaturation(r, g, b);
|
|
|
|
// Reduce color precision for better grouping
|
|
const reducedR = Math.floor(r / 15) * 15;
|
|
const reducedG = Math.floor(g / 15) * 15;
|
|
const reducedB = Math.floor(b / 15) * 15;
|
|
const key = `${reducedR},${reducedG},${reducedB}`;
|
|
|
|
const existing = colorMap.get(key);
|
|
if (existing) {
|
|
existing.count += 1;
|
|
existing.saturation = Math.max(
|
|
existing.saturation,
|
|
saturation
|
|
);
|
|
} else {
|
|
colorMap.set(key, { count: 1, saturation });
|
|
}
|
|
}
|
|
|
|
// Sort colors by a weighted score (frequency + saturation)
|
|
const sortedColors = Array.from(colorMap.entries())
|
|
.sort((a, b) => {
|
|
// Weight both frequency and saturation
|
|
const scoreA = a[1].count * (1 + a[1].saturation * 2);
|
|
const scoreB = b[1].count * (1 + b[1].saturation * 2);
|
|
return scoreB - scoreA;
|
|
})
|
|
.map(([color]) => {
|
|
const [r, g, b] = color.split(",").map(Number);
|
|
return { r, g, b };
|
|
});
|
|
|
|
if (sortedColors.length === 0) {
|
|
// Fallback colors
|
|
resolve({
|
|
vibrant: "#1db954",
|
|
darkVibrant: "#121212",
|
|
lightVibrant: "#181818",
|
|
muted: "#535353",
|
|
darkMuted: "#121212",
|
|
lightMuted: "#b3b3b3",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Find vibrant color (highest saturation) and boost if too dark
|
|
const vibrantColor = sortedColors.reduce((prev, curr) => {
|
|
const prevSat = getSaturation(prev.r, prev.g, prev.b);
|
|
const currSat = getSaturation(curr.r, curr.g, curr.b);
|
|
return currSat > prevSat ? curr : prev;
|
|
});
|
|
|
|
// Boost vibrant color if it's too dark - be VERY aggressive
|
|
const vibrantBrightness =
|
|
(vibrantColor.r + vibrantColor.g + vibrantColor.b) / 3;
|
|
let boostFactor: number;
|
|
|
|
if (vibrantBrightness < 30) {
|
|
// Extremely dark - boost by 8-10x
|
|
boostFactor = 10.0;
|
|
} else if (vibrantBrightness < 60) {
|
|
// Very dark - boost by 4-5x
|
|
boostFactor = 5.0;
|
|
} else if (vibrantBrightness < 100) {
|
|
// Dark - boost by 2-3x
|
|
boostFactor = 3.0;
|
|
} else if (vibrantBrightness < 140) {
|
|
// Somewhat dark - boost by 1.5-2x
|
|
boostFactor = 2.0;
|
|
} else {
|
|
// Normal brightness
|
|
boostFactor = 1.3;
|
|
}
|
|
|
|
let boostedVibrant = {
|
|
r: Math.min(255, Math.floor(vibrantColor.r * boostFactor)),
|
|
g: Math.min(255, Math.floor(vibrantColor.g * boostFactor)),
|
|
b: Math.min(255, Math.floor(vibrantColor.b * boostFactor)),
|
|
};
|
|
|
|
// Ensure minimum brightness - if still too dark, add a base amount
|
|
const boostedBrightness =
|
|
(boostedVibrant.r + boostedVibrant.g + boostedVibrant.b) /
|
|
3;
|
|
if (boostedBrightness < 80) {
|
|
const addAmount = 80 - boostedBrightness;
|
|
boostedVibrant = {
|
|
r: Math.min(255, boostedVibrant.r + addAmount),
|
|
g: Math.min(255, boostedVibrant.g + addAmount),
|
|
b: Math.min(255, boostedVibrant.b + addAmount),
|
|
};
|
|
}
|
|
|
|
// Find dark vibrant (vibrant but darker than original, not boosted)
|
|
const darkVibrantColor = {
|
|
r: Math.floor(vibrantColor.r * 0.6),
|
|
g: Math.floor(vibrantColor.g * 0.6),
|
|
b: Math.floor(vibrantColor.b * 0.6),
|
|
};
|
|
|
|
// Find light vibrant (from boosted vibrant)
|
|
const lightVibrantColor = {
|
|
r: Math.min(255, Math.floor(boostedVibrant.r * 1.2)),
|
|
g: Math.min(255, Math.floor(boostedVibrant.g * 1.2)),
|
|
b: Math.min(255, Math.floor(boostedVibrant.b * 1.2)),
|
|
};
|
|
|
|
// Find muted color (low saturation)
|
|
const mutedColor = sortedColors.reduce((prev, curr) => {
|
|
const prevSat = getSaturation(prev.r, prev.g, prev.b);
|
|
const currSat = getSaturation(curr.r, curr.g, curr.b);
|
|
return currSat < prevSat ? curr : prev;
|
|
});
|
|
|
|
// Dark muted
|
|
const darkMutedColor = {
|
|
r: Math.floor(mutedColor.r * 0.4),
|
|
g: Math.floor(mutedColor.g * 0.4),
|
|
b: Math.floor(mutedColor.b * 0.4),
|
|
};
|
|
|
|
// Light muted
|
|
const lightMutedColor = {
|
|
r: Math.min(255, Math.floor(mutedColor.r * 1.5)),
|
|
g: Math.min(255, Math.floor(mutedColor.g * 1.5)),
|
|
b: Math.min(255, Math.floor(mutedColor.b * 1.5)),
|
|
};
|
|
|
|
resolve({
|
|
vibrant: rgbToHex(
|
|
boostedVibrant.r,
|
|
boostedVibrant.g,
|
|
boostedVibrant.b
|
|
),
|
|
darkVibrant: rgbToHex(
|
|
darkVibrantColor.r,
|
|
darkVibrantColor.g,
|
|
darkVibrantColor.b
|
|
),
|
|
lightVibrant: rgbToHex(
|
|
lightVibrantColor.r,
|
|
lightVibrantColor.g,
|
|
lightVibrantColor.b
|
|
),
|
|
muted: rgbToHex(mutedColor.r, mutedColor.g, mutedColor.b),
|
|
darkMuted: rgbToHex(
|
|
darkMutedColor.r,
|
|
darkMutedColor.g,
|
|
darkMutedColor.b
|
|
),
|
|
lightMuted: rgbToHex(
|
|
lightMutedColor.r,
|
|
lightMutedColor.g,
|
|
lightMutedColor.b
|
|
),
|
|
});
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
img.onerror = () => {
|
|
reject(new Error("Failed to load image"));
|
|
};
|
|
|
|
img.src = imageUrl;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Calculate saturation of an RGB color
|
|
*/
|
|
function getSaturation(r: number, g: number, b: number): number {
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
const delta = max - min;
|
|
return max === 0 ? 0 : delta / max;
|
|
}
|
|
|
|
/**
|
|
* Convert RGB to hex color
|
|
*/
|
|
function rgbToHex(r: number, g: number, b: number): string {
|
|
return (
|
|
"#" +
|
|
[r, g, b]
|
|
.map((x) => {
|
|
const hex = x.toString(16);
|
|
return hex.length === 1 ? "0" + hex : hex;
|
|
})
|
|
.join("")
|
|
);
|
|
}
|
|
|
|
export function useImageColor(imageUrl: string | null | undefined) {
|
|
const [colors, setColors] = useState<ColorPalette | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!imageUrl) {
|
|
setColors(null);
|
|
return;
|
|
}
|
|
|
|
// Handle placeholder images immediately
|
|
if (
|
|
imageUrl.includes("placeholder") ||
|
|
imageUrl.startsWith("/placeholder")
|
|
) {
|
|
setColors({
|
|
vibrant: "#1db954",
|
|
darkVibrant: "#121212",
|
|
lightVibrant: "#181818",
|
|
muted: "#535353",
|
|
darkMuted: "#121212",
|
|
lightMuted: "#b3b3b3",
|
|
});
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Check cache first
|
|
const cacheKey = `color_cache_${imageUrl}`;
|
|
try {
|
|
const cached = localStorage.getItem(cacheKey);
|
|
if (cached) {
|
|
const cachedPalette = JSON.parse(cached) as ColorPalette;
|
|
setColors(cachedPalette);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
// Ignore cache read errors
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
// Extract colors client-side using canvas
|
|
extractColorsFromImage(imageUrl)
|
|
.then((palette: ColorPalette) => {
|
|
setColors(palette);
|
|
setIsLoading(false);
|
|
|
|
// Cache the result in localStorage
|
|
try {
|
|
localStorage.setItem(cacheKey, JSON.stringify(palette));
|
|
} catch (error) {
|
|
// Ignore cache write errors
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("[useImageColor] Failed to extract colors:", error.message || error);
|
|
|
|
// Use fallback colors on error
|
|
setColors({
|
|
vibrant: "#1db954",
|
|
darkVibrant: "#121212",
|
|
lightVibrant: "#181818",
|
|
muted: "#535353",
|
|
darkMuted: "#121212",
|
|
lightMuted: "#b3b3b3",
|
|
});
|
|
setIsLoading(false);
|
|
|
|
// Remove any cached failures so it can retry later
|
|
try {
|
|
localStorage.removeItem(cacheKey);
|
|
} catch (e) {
|
|
// Ignore
|
|
}
|
|
});
|
|
}, [imageUrl]);
|
|
|
|
return { colors, isLoading };
|
|
}
|
|
|
|
/**
|
|
* Generate a gradient style object from extracted colors
|
|
*/
|
|
export function createGradient(
|
|
colors: ColorPalette | null,
|
|
fallbackColor: string = "#1db954"
|
|
): React.CSSProperties {
|
|
if (!colors) {
|
|
return {
|
|
background: `linear-gradient(180deg, ${fallbackColor}40 0%, rgba(0, 0, 0, 0) 100%)`,
|
|
};
|
|
}
|
|
|
|
// Create a gradient from dark vibrant to transparent black
|
|
return {
|
|
background: `linear-gradient(180deg, ${colors.darkVibrant} 0%, ${colors.darkMuted} 40%, rgba(0, 0, 0, 0.8) 70%, #000000 100%)`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate a hero gradient (for the top section)
|
|
*/
|
|
export function createHeroGradient(
|
|
colors: ColorPalette | null,
|
|
fallbackColor: string = "#1db954"
|
|
): React.CSSProperties {
|
|
if (!colors) {
|
|
return {
|
|
background: `linear-gradient(180deg, ${fallbackColor}60 0%, rgba(18, 18, 18, 0.9) 100%)`,
|
|
};
|
|
}
|
|
|
|
// Create a more vibrant gradient for the hero section
|
|
return {
|
|
background: `linear-gradient(180deg, ${colors.vibrant}40 0%, ${colors.darkVibrant}80 50%, rgba(18, 18, 18, 0.95) 100%)`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate relative luminance for contrast ratio
|
|
* Based on WCAG 2.0 formula: https://www.w3.org/TR/WCAG20/#relativeluminancedef
|
|
*/
|
|
function getLuminance(r: number, g: number, b: number): number {
|
|
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
c = c / 255;
|
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
});
|
|
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
}
|
|
|
|
/**
|
|
* Calculate contrast ratio between two colors
|
|
* Returns a value between 1 and 21 (higher is better contrast)
|
|
* WCAG AA requires 4.5:1 for normal text, 3:1 for large text
|
|
*/
|
|
function getContrastRatio(hex1: string, hex2: string): number {
|
|
const rgb1 = hexToRgb(hex1);
|
|
const rgb2 = hexToRgb(hex2);
|
|
|
|
if (!rgb1 || !rgb2) return 1;
|
|
|
|
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
|
|
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
|
|
|
|
const lighter = Math.max(lum1, lum2);
|
|
const darker = Math.min(lum1, lum2);
|
|
|
|
return (lighter + 0.05) / (darker + 0.05);
|
|
}
|
|
|
|
/**
|
|
* Convert hex color to RGB
|
|
*/
|
|
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result
|
|
? {
|
|
r: parseInt(result[1], 16),
|
|
g: parseInt(result[2], 16),
|
|
b: parseInt(result[3], 16),
|
|
}
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Get the best icon color (black or white) for a given background color
|
|
* Returns 'black' or 'white' based on contrast ratio
|
|
*/
|
|
export function getIconColor(backgroundColor: string): "black" | "white" {
|
|
const whiteContrast = getContrastRatio(backgroundColor, "#ffffff");
|
|
const blackContrast = getContrastRatio(backgroundColor, "#000000");
|
|
|
|
// Return the color with better contrast
|
|
// Use white if contrast is close (within 1.5), as it looks better on colorful backgrounds
|
|
return whiteContrast > blackContrast - 1.5 ? "white" : "black";
|
|
}
|
|
|
|
/**
|
|
* Get play button styles based on vibrant color
|
|
* Returns background color and icon color with proper contrast
|
|
*/
|
|
export function getPlayButtonStyles(colors: ColorPalette | null): {
|
|
backgroundColor: string;
|
|
iconColor: "black" | "white";
|
|
} {
|
|
if (!colors) {
|
|
return {
|
|
backgroundColor: "#facc15", // Yellow fallback
|
|
iconColor: "black",
|
|
};
|
|
}
|
|
|
|
// Use the vibrant color for the button
|
|
const bgColor = colors.vibrant;
|
|
const iconColor = getIconColor(bgColor);
|
|
|
|
return {
|
|
backgroundColor: bgColor,
|
|
iconColor,
|
|
};
|
|
}
|