From c77334a8073c90bfb894f27ce89e37a60661ca15 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sun, 4 Jan 2026 02:20:41 +0100 Subject: [PATCH] NEW: vibrant color for artists, mix and playlists --- js/ui.js | 51 ++++++++++++--- js/vibrant-color.js | 147 ++++++++++++++++++++++++++++++++++++++++++++ styles.css | 8 +-- 3 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 js/vibrant-color.js diff --git a/js/ui.js b/js/ui.js index 32fdf27..e53d6ad 100644 --- a/js/ui.js +++ b/js/ui.js @@ -3,6 +3,7 @@ import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, SVG_HEART, formatTime, createPlacehol import { openLyricsPanel } from './lyrics.js'; import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js'; import { db } from './db.js'; +import { getVibrantColorFromImage } from './vibrant-color.js'; export class UIRenderer { constructor(api, player) { @@ -20,6 +21,36 @@ export class UIRenderer { return SVG_HEART; } + async extractAndApplyColor(url) { + if (!backgroundSettings.isEnabled() || !url) { + this.resetVibrantColor(); + return; + } + + const img = new Image(); + img.crossOrigin = 'Anonymous'; + // Add cache buster to bypass opaque response in cache + const separator = url.includes('?') ? '&' : '?'; + img.src = `${url}${separator}not-from-cache-please`; + + img.onload = () => { + try { + const color = getVibrantColorFromImage(img); + if (color) { + this.setVibrantColor(color); + } else { + this.resetVibrantColor(); + } + } catch (e) { + this.resetVibrantColor(); + } + }; + + img.onerror = () => { + this.resetVibrantColor(); + }; + } + async updateLikeState(element, type, id) { const isLiked = await db.isFavorite(type, id); const btn = element.querySelector('.like-btn'); @@ -406,9 +437,9 @@ export class UIRenderer { // We need the color to be light enough. // If brightness is too low (< 80), lighten it. while (brightness < 80) { - r = Math.min(255, Math.floor(r * 1.15)); - g = Math.min(255, Math.floor(g * 1.15)); - b = Math.min(255, Math.floor(b * 1.15)); + r = Math.min(255, Math.max(r + 1, Math.floor(r * 1.15))); + g = Math.min(255, Math.max(g + 1, Math.floor(g * 1.15))); + b = Math.min(255, Math.max(b + 1, Math.floor(b * 1.15))); brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000; // Break if we hit white or can't get brighter to avoid infinite loop if (r >= 255 && g >= 255 && b >= 255) break; @@ -817,7 +848,7 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) { if (backgroundSettings.isEnabled() && album.vibrantColor) { this.setVibrantColor(album.vibrantColor); } else { - this.resetVibrantColor(); + this.extractAndApplyColor(this.api.getCoverUrl(album.cover, '80')); } const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : ''; @@ -1090,12 +1121,13 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) { if (imageId) { imageEl.src = this.api.getCoverUrl(imageId, '1080'); this.setPageBackground(imageEl.src); + + this.extractAndApplyColor(this.api.getCoverUrl(imageId, '160')); } else { imageEl.src = 'assets/appicon.png'; this.setPageBackground(null); + this.resetVibrantColor(); } - this.resetVibrantColor(); - imageEl.style.backgroundColor = ''; titleEl.textContent = playlist.title; this.adjustTitleFontSize(titleEl, playlist.title); @@ -1181,11 +1213,13 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) { if (imageId && imageId !== mix.id) { imageEl.src = this.api.getCoverUrl(imageId); this.setPageBackground(imageEl.src); + this.extractAndApplyColor(this.api.getCoverUrl(imageId, '160')); } else { // Try to get cover from first track album if (tracks.length > 0 && tracks[0].album?.cover) { imageEl.src = this.api.getCoverUrl(tracks[0].album.cover); this.setPageBackground(imageEl.src); + this.extractAndApplyColor(this.api.getCoverUrl(tracks[0].album.cover, '160')); } else { imageEl.src = 'assets/appicon.png'; this.setPageBackground(null); @@ -1299,7 +1333,10 @@ async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) { // Set background this.setPageBackground(imageEl.src); - this.resetVibrantColor(); // Ensure we don't carry over color from previous page + + // Extract vibrant color using robust image extraction (160x160 for speed/accuracy balance) + const artistPic160 = this.api.getArtistPictureUrl(artist.picture, '160'); + this.extractAndApplyColor(artistPic160); this.adjustTitleFontSize(nameEl, artist.name); diff --git a/js/vibrant-color.js b/js/vibrant-color.js new file mode 100644 index 0000000..b9250e1 --- /dev/null +++ b/js/vibrant-color.js @@ -0,0 +1,147 @@ +/** + * Converts an RGB color value to HSL. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h, s, and l in the set [0, 1]. + */ +function rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + + if (max === min) { + h = s = 0; // achromatic + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + + return [h, s, l]; +} + +/** + * Converts an HSL color value to RGB hex string. + */ +function hslToHex(h, s, l) { + let r, g, b; + + if (s === 0) { + r = g = b = l; // achromatic + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + const toHex = x => { + const hex = Math.round(x * 255).toString(16); + return hex.length === 1 ? '0' + hex : hex; + }; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +export function getVibrantColorFromImage(imgElement) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + canvas.width = imgElement.naturalWidth || imgElement.width; + canvas.height = imgElement.naturalHeight || imgElement.height; + + try { + ctx.drawImage(imgElement, 0, 0); + + // Downscale for performance if image is large + const maxDimension = 64; + if (canvas.width > maxDimension || canvas.height > maxDimension) { + const scale = Math.min(maxDimension / canvas.width, maxDimension / canvas.height); + const w = Math.floor(canvas.width * scale); + const h = Math.floor(canvas.height * scale); + const smallCanvas = document.createElement('canvas'); + smallCanvas.width = w; + smallCanvas.height = h; + smallCanvas.getContext('2d').drawImage(imgElement, 0, 0, w, h); + ctx.drawImage(smallCanvas, 0, 0, canvas.width, canvas.height); + // Actually, better to just use the small canvas data + var imageData = smallCanvas.getContext('2d').getImageData(0, 0, w, h); + } else { + var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + } + + const pixels = imageData.data; + const candidates = []; + + // Iterate through pixels + 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]; + + if (a < 125) continue; // Skip transparent + + const [h, s, l] = rgbToHsl(r, g, b); + + // Filter out very dark, very bright, or very desaturated pixels for the "vibrant" candidate list + // Vibrant: High saturation (s > 0.3), Moderate lightness (0.3 < l < 0.8) + if (s >= 0.3 && l >= 0.3 && l <= 0.8) { + candidates.push({ r, g, b, h, s, l }); + } + } + + // If no candidates found with strict criteria, relax criteria + if (candidates.length === 0) { + 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]; + if (a < 125) continue; + const [h, s, l] = rgbToHsl(r, g, b); + // Allow anything not practically black or white + if (l > 0.1 && l < 0.95) { + candidates.push({ r, g, b, h, s, l }); + } + } + } + + // If still no candidates, return null (caller will handle fallback to default) + if (candidates.length === 0) return null; + + // Sort by saturation (descending) then lightness (proximity to 0.5) + candidates.sort((c1, c2) => { + return c2.s - c1.s || (0.5 - Math.abs(c1.l - 0.5)) - (0.5 - Math.abs(c2.l - 0.5)); + }); + + // Pick the top candidate (most vibrant) + // Optionally averaging top N could be done, but simplified "best single pixel" is usually sufficient for "Vibrant" + const best = candidates[0]; + + return hslToHex(best.h, best.s, best.l); + + } catch (e) { + throw e; // Re-throw to allow UI to handle CORS retry + } +} \ No newline at end of file diff --git a/styles.css b/styles.css index e04aeeb..7aed88f 100644 --- a/styles.css +++ b/styles.css @@ -13,7 +13,7 @@ --shadow-md: 0 6px 16px rgba(0, 0, 0, 0.2); --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5); --shadow-xl: 0 20px 60px rgba(0, 0, 0, 0.8); - --cover-filter: blur(30px) brightness(0.3); + --cover-filter: blur(50px) brightness(0.4); } :root[data-theme="monochrome"] { @@ -35,7 +35,6 @@ --highlight-rgb: 255, 255, 255; --active-highlight: var(--highlight); --explicit-badge: #fafafa; - --cover-filter: blur(30px) brightness(0.3); } :root[data-theme="dark"] { @@ -225,7 +224,7 @@ --highlight-rgb: 37, 99, 235; --active-highlight: var(--highlight); --explicit-badge: #f58a8a; - --cover-filter: blur(30px) brightness(1.6) opacity(0.85); + --cover-filter: blur(50px) brightness(1.6) opacity(0.35); } *, *::before, *::after { @@ -339,7 +338,7 @@ kbd { -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0.8) 40%, rgba(0,0,0,0) 100%); /* Blur effect */ - filter: blur(50px) saturate(1.4) brightness(0.5); + filter: var(--cover-filter); pointer-events: none; } @@ -349,7 +348,6 @@ kbd { /* Light mode adjustments */ :root[data-theme="light"] #page-background { - filter: blur(50px) saturate(1.5) brightness(1.1) opacity(0.5); mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%); -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%); }