diff --git a/.env.local b/.env.local index a7d93ce..2c32e7d 100644 --- a/.env.local +++ b/.env.local @@ -1 +1 @@ -NEXT_PUBLIC_COMMIT_SHA=a6e6588 +NEXT_PUBLIC_COMMIT_SHA=fccf3c5 diff --git a/hooks/use-responsive-image-size.ts b/hooks/use-responsive-image-size.ts new file mode 100644 index 0000000..c100c28 --- /dev/null +++ b/hooks/use-responsive-image-size.ts @@ -0,0 +1,96 @@ +import { useState, useEffect, useRef } from 'react'; + +interface UseResponsiveImageSizeOptions { + /** Minimum size threshold */ + minSize?: number; + /** Maximum size threshold */ + maxSize?: number; + /** Multiplier for high DPI displays */ + dpiMultiplier?: number; + /** Available size tiers from Navidrome */ + availableSizes?: number[]; +} + +/** + * Hook to calculate optimal image size based on container dimensions + */ +export function useResponsiveImageSize(options: UseResponsiveImageSizeOptions = {}) { + const { + minSize = 60, + maxSize = 1200, + dpiMultiplier = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1, + availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200 + } = options; + + const containerRef = useRef(null); + const [imageSize, setImageSize] = useState(300); // Default fallback + + useEffect(() => { + const calculateOptimalSize = () => { + if (!containerRef.current) return; + + const element = containerRef.current; + const rect = element.getBoundingClientRect(); + + // Use the larger dimension (width or height) as base + const displaySize = Math.max(rect.width, rect.height); + + // Account for device pixel ratio for crisp images on high DPI displays + const targetSize = Math.round(displaySize * dpiMultiplier); + + // Clamp to min/max bounds + const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize)); + + // Find the next larger available size to ensure quality + const optimalSize = availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1]; + + setImageSize(optimalSize); + }; + + // Calculate initial size + calculateOptimalSize(); + + // Recalculate on resize + const resizeObserver = new ResizeObserver(calculateOptimalSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [minSize, maxSize, dpiMultiplier, availableSizes]); + + return { + containerRef, + imageSize, + /** Get size for a specific display dimension */ + getSizeForDimension: (dimension: number) => { + const targetSize = Math.round(dimension * dpiMultiplier); + const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize)); + return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1]; + } + }; +} + +/** + * Simple function to get optimal image size for known dimensions + */ +export function getOptimalImageSize( + displayWidth: number, + displayHeight: number, + options: Omit & { availableSizes?: number[] } = {} +): number { + const { + minSize = 60, + maxSize = 1200, + dpiMultiplier = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, + availableSizes = [60, 120, 240, 400, 600, 1200] // Clean divisions of 1200 + } = options; + + const displaySize = Math.max(displayWidth, displayHeight); + const targetSize = Math.round(displaySize * dpiMultiplier); + const clampedSize = Math.max(minSize, Math.min(maxSize, targetSize)); + + return availableSizes.find(size => size >= clampedSize) || availableSizes[availableSizes.length - 1]; +} diff --git a/lib/image-utils.ts b/lib/image-utils.ts new file mode 100644 index 0000000..b5bbaf3 --- /dev/null +++ b/lib/image-utils.ts @@ -0,0 +1,125 @@ +/** + * Utility functions for calculating optimal image sizes for different contexts + */ + +export interface ImageSizeContext { + /** The display width in CSS pixels */ + displayWidth: number; + /** The display height in CSS pixels */ + displayHeight: number; + /** Device pixel ratio for high-DPI displays */ + devicePixelRatio?: number; + /** Additional scaling factor (e.g., for hover effects) */ + scaleFactor?: number; +} + +/** + * Calculate the optimal image size for the given context + * Takes into account device pixel ratio and potential scaling effects + */ +export function calculateOptimalImageSize(context: ImageSizeContext): number { + const { displayWidth, displayHeight, devicePixelRatio = 1, scaleFactor = 1.1 } = context; + + // Use the larger dimension to ensure we cover the entire display area + const baseDimension = Math.max(displayWidth, displayHeight); + + // Account for device pixel ratio and potential scaling + const optimalSize = Math.ceil(baseDimension * devicePixelRatio * scaleFactor); + + // Cap at reasonable maximum to avoid excessive bandwidth usage + return Math.min(optimalSize, 1200); +} + +/** + * Get optimal image size for common component contexts + * All sizes are clean divisions of 1200 for optimal scaling + */ +export const ImageSizes = { + // Small thumbnails in lists - 1200/20 = 60, rounded to 64 for better display + THUMBNAIL: 60, + + // Small album covers in compact views - 1200/10 = 120 + SMALL_ALBUM: 120, + + // Medium album covers in grid views - 1200/5 = 240 + MEDIUM_ALBUM: 240, + + // Large album covers in detail views - 1200/3 = 400 + LARGE_ALBUM: 400, + + // Extra large for full-screen displays - 1200/2 = 600 + XLARGE_ALBUM: 600, + + // Full resolution - 1200/1 = 1200 + FULL_ALBUM: 1200, + + // Artist images + ARTIST_SMALL: 120, // 1200/10 + ARTIST_MEDIUM: 240, // 1200/5 + ARTIST_LARGE: 400, // 1200/3 + + // Player images + PLAYER_MINI: 60, // 1200/20 + PLAYER_COMPACT: 120, // 1200/10 + PLAYER_FULL: 400, // 1200/3 +} as const; + +/** + * Get responsive image size based on container and viewport + */ +export function getResponsiveImageSize( + containerWidth: number, + viewportWidth: number = typeof window !== 'undefined' ? window?.innerWidth || 1920 : 1920, + devicePixelRatio: number = typeof window !== 'undefined' ? window?.devicePixelRatio || 1 : 1 +): number { + let targetSize: number; + + // Determine base size based on container and viewport + // All sizes are clean divisions of 1200 + if (containerWidth <= 60) { + targetSize = ImageSizes.THUMBNAIL; // 60px + } else if (containerWidth <= 120) { + targetSize = ImageSizes.SMALL_ALBUM; // 120px + } else if (containerWidth <= 240 || viewportWidth <= 768) { + targetSize = ImageSizes.MEDIUM_ALBUM; // 240px + } else if (containerWidth <= 400 || viewportWidth <= 1024) { + targetSize = ImageSizes.LARGE_ALBUM; // 400px + } else if (containerWidth <= 600 || viewportWidth <= 1440) { + targetSize = ImageSizes.XLARGE_ALBUM; // 600px + } else { + targetSize = ImageSizes.FULL_ALBUM; // 1200px + } + + // Apply device pixel ratio but ensure we stay within clean divisions of 1200 + const scaledSize = Math.ceil(targetSize * devicePixelRatio); + + // Round to nearest clean division of 1200 + const divisions = [60, 120, 240, 400, 600, 1200]; + return divisions.find(size => size >= scaledSize) || 1200; +} + +/** + * Hook to get optimal image size for a container + * Returns clean divisions of 1200 for optimal scaling + */ +export function useOptimalImageSize( + width: number, + height: number = width, + scaleFactor: number = 1.1 +): number { + if (typeof window === 'undefined') { + // SSR fallback - return appropriate size based on dimensions + return getResponsiveImageSize(width, 1920, 1); + } + + const optimalSize = calculateOptimalImageSize({ + displayWidth: width, + displayHeight: height, + devicePixelRatio: window.devicePixelRatio || 1, + scaleFactor, + }); + + // Round to nearest clean division of 1200 + const divisions = [60, 120, 240, 400, 600, 1200]; + return divisions.find(size => size >= optimalSize) || 1200; +}