NEW: vibrant color for artists, mix and playlists

This commit is contained in:
Julien Maille 2026-01-04 02:20:41 +01:00
parent 59e79d071a
commit c77334a807
3 changed files with 194 additions and 12 deletions

View file

@ -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);

147
js/vibrant-color.js Normal file
View file

@ -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
}
}

View file

@ -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%);
}