NEW: vibrant color for artists, mix and playlists
This commit is contained in:
parent
59e79d071a
commit
c77334a807
3 changed files with 194 additions and 12 deletions
51
js/ui.js
51
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);
|
||||
|
||||
|
|
|
|||
147
js/vibrant-color.js
Normal file
147
js/vibrant-color.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue