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 { 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(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, }; }