-
-
Notifications
You must be signed in to change notification settings - Fork 4
Full Album view code
Anup Chavan edited this page Jun 21, 2026
·
1 revision
This page contains the full saved Albums view from data.json.
{
"type": "group",
"operator": "AND",
"conditions": [
{
"type": "filter",
"field": "categories",
"operator": "contains",
"value": "[[Albums]]"
}
]
}{% base "Songs" %}
views:
- type: table
name: Songs
filters:
and:
- list(album).contains(this)
order:
- track
- file.name
- artists
sort:
- property: track
direction: ASC
{% endbase %}
<div class="album-view" data-cover="{{cover[0]}}">
<div class="album-shell">
<div class="album-layout">
<section class="album-info">
<img class="album-cover" src="{{cover[0]}}" alt="{{file.basename}}" crossorigin="anonymous" />
<div class="album-copy">
<div class="album-heading">
<h1>{{file.basename.split(" – ").last()}}</h1>
<div class="album-artists">{{artists}}</div>
</div>
<div class="album-meta">
<span>{{year}}</span>
<span>·</span>
<span>{{genres}}</span>
</div>
<p class="album-description">{{description}}</p>
</div>
</section>
<section class="album-tracks">
{% for row in bases.Songs.rows %}
<div class="album-track">
<div class="album-track-number">{{row.values.track}}</div>
<div class="album-track-body">
<div class="album-track-title">{{row.file.link}}</div>
{% if row.values.artists.length > 0 && !(row.values.artists.length == 1 && artists.length == 1 && row.values.artists[0] == artists[0]) %}
<div class="album-track-artists">{{row.values.artists}}</div>
{% endif %}
</div>
</div>
{% endfor %}
</section>
</div>
</div>
</div>.obsidian-custom-view-render {
padding: 0;
}
.album-view {
--color-bg: #d34e59;
--text-normal: #feefe4;
--text-muted: #ffd2c8;
--text-faint: #f9aba4;
width: 100%;
min-height: 100%;
background: var(--color-bg, #d34e59);
color: var(--text-normal, #feefe4);
}
.album-shell {
width: 1728px;
padding: 96px;
box-sizing: border-box;
overflow: clip;
background: var(--color-bg, #d34e59);
}
.album-layout {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
align-content: flex-start;
gap: 95px;
}
.album-info {
flex: 1 0 0;
min-width: 460px;
display: flex;
flex-direction: column;
align-items: center;
gap: 54px;
}
.album-cover {
width: 100%;
aspect-ratio: 4000 / 4000;
display: block;
object-fit: cover;
border-radius: 16px;
box-shadow: 0 4px 32px 10px rgba(16, 15, 15, 0.25);
}
.album-copy {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.album-heading {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
font-style: normal;
word-break: break-word;
}
.album-heading h1 {
width: 100%;
margin: 0;
font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 48px;
font-weight: 500;
line-height: 1.27;
letter-spacing: -0.6106px;
color: var(--text-normal, #feefe4);
}
.album-artists,
.album-artists a {
width: 100%;
font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 28px;
font-weight: 400;
line-height: 1.6;
letter-spacing: -0.173px;
color: var(--text-muted, #ffd2c8);
text-decoration: none;
}
.album-meta {
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
word-break: break-word;
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 22px;
font-weight: 500;
font-style: normal;
line-height: 1.6;
letter-spacing: -0.66px;
color: var(--text-faint, #f9aba4);
}
.album-description {
width: 100%;
margin: 0;
padding-top: 16px;
box-sizing: border-box;
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 22px;
font-weight: 400;
font-style: normal;
line-height: 1.6;
letter-spacing: -0.66px;
color: var(--text-muted, #ffd2c8);
word-break: break-word;
}
.album-tracks {
width: 794px;
min-width: 850px;
max-width: 1000px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 36px;
font-family: "SF Pro Text", -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: 400;
font-style: normal;
line-height: 1.6;
word-break: break-word;
}
.album-track {
width: 100%;
display: flex;
align-items: flex-start;
gap: 24px;
}
.album-track-number {
width: 32px;
flex: 0 0 32px;
text-align: right;
font-size: 24px;
line-height: 1.6;
letter-spacing: -0.72px;
color: var(--text-faint, #f9aba4);
}
.album-track-body {
flex: 1 0 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.album-track-title,
.album-track-title a {
width: 100%;
font-size: 24px;
line-height: 1.6;
letter-spacing: -0.72px;
color: var(--text-normal, #feefe4);
text-decoration: none;
}
.album-track-artists,
.album-track-artists a {
width: 100%;
font-size: 18px;
line-height: 1.6;
letter-spacing: -0.54px;
color: var(--text-faint, #f9aba4);
text-decoration: none;
}const container = this;
const doc = container.ownerDocument;
const FALLBACK_COLOR = "#d34e59";
const VIBRANCY_BOOST = 1.18;
const PAPER = "#FFFCF0";
const BLACK = "#100F0F";
const CURVES = {
pre400: [[0, 0], [0.5, 0.3], [0.5, 0.7], [1, 1]],
pre400Chroma: [[0, 0], [0.1, 0.5], [0.5, 0.9], [1, 1]],
post600: [[0, 1], [0.5, 0.8], [0.5, 0.4], [1, 0]],
post600Chroma: [[0, 1], [0.5, 0.9], [0.9, 0.5], [1, 0]],
};
function clamp(value, min = 0, max = 1) {
return Math.max(min, Math.min(max, value));
}
function hexToRgb(hex) {
const h = hex.replace("#", "");
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
function rgbToHex(r, g, b) {
return "#" + [r, g, b]
.map(v => Math.round(clamp(v) * 255).toString(16).padStart(2, "0"))
.join("");
}
function rgbToHsl(r, g, b) {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l * 100];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return [h * 360, s * 100, l * 100];
}
function srgbToLinear(v) {
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
}
function linearToSrgb(v) {
return v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
}
function hexToOklch(hex) {
const [r, g, b] = hexToRgb(hex).map(srgbToLinear);
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
const bk = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
return [L, Math.hypot(a, bk), (Math.atan2(bk, a) * 180 / Math.PI + 360) % 360];
}
function oklchToLinearRgb(L, C, H) {
const a = C * Math.cos(H * Math.PI / 180);
const b = C * Math.sin(H * Math.PI / 180);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
const l = l_ ** 3;
const m = m_ ** 3;
const s = s_ ** 3;
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
];
}
function oklchToHex(L, C, H) {
return rgbToHex(...oklchToLinearRgb(L, C, H).map(linearToSrgb));
}
function inGamut(L, C, H, eps = 1e-4) {
return oklchToLinearRgb(L, C, H).every(v => v >= -eps && v <= 1 + eps);
}
function oklchToHexGamut(L, C, H) {
L = clamp(L);
C = Math.max(0, C);
if (inGamut(L, C, H)) return oklchToHex(L, C, H);
let lo = 0;
let hi = C;
for (let i = 0; i < 30; i++) {
const mid = (lo + hi) / 2;
if (inGamut(L, mid, H)) lo = mid;
else hi = mid;
}
return oklchToHex(L, lo, H);
}
function multiplyColors(hex1, hex2) {
const [r1, g1, b1] = hexToRgb(hex1);
const [r2, g2, b2] = hexToRgb(hex2);
return rgbToHex(r1 * r2, g1 * g2, b1 * b2);
}
function bezierY(points, t) {
const y0 = points[0][1];
const y1 = points[1][1];
const y2 = points[2][1];
const y3 = points[3][1];
const u = 1 - t;
return u ** 3 * y0 + 3 * u ** 2 * t * y1 + 3 * u * t ** 2 * y2 + t ** 3 * y3;
}
function rampColor(step, L400, C400, L600, C600, Lp, Lb, H0) {
if (step <= 400) {
const t = step / 400;
const L = Lp + (L400 - Lp) * bezierY(CURVES.pre400, t);
const C = C400 * bezierY(CURVES.pre400Chroma, t);
return multiplyColors(oklchToHexGamut(L, C, H0), PAPER);
}
const t = (step - 600) / 400;
const L = L600 + (Lb - L600) * (1 - bezierY(CURVES.post600, t));
const C = C600 * bezierY(CURVES.post600Chroma, t);
return oklchToHexGamut(L, C, H0);
}
function buildScale(baseHex) {
const [L0, C0, H0] = hexToOklch(baseHex);
const [Lp] = hexToOklch(PAPER);
const [Lb] = hexToOklch(BLACK);
const L400 = Lp + (L0 - Lp) * 0.88;
const C400 = C0 * 0.82;
const L600 = L0 + (Lb - L0) * 0.18;
const C600 = C0 * 0.92;
const scale = {};
for (const step of [50, 100, 150, 200, 300, 400, 500, 600, 700, 800, 850, 900, 950]) {
if (step === 500) scale[step] = baseHex;
else if (step === 400) scale[step] = multiplyColors(oklchToHexGamut(L400, C400, H0), PAPER);
else if (step === 600) scale[step] = oklchToHexGamut(L600, C600, H0);
else scale[step] = rampColor(step, L400, C400, L600, C600, Lp, Lb, H0);
}
scale.paper = rampColor(25, L400, C400, L600, C600, Lp, Lb, H0);
scale.black = rampColor(975, L400, C400, L600, C600, Lp, Lb, H0);
return scale;
}
function seededRandom(seed = 0) {
let t = seed + 0x6d2b79f5;
return () => {
t += 0x6d2b79f5;
let x = Math.imul(t ^ (t >>> 15), t | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
function kmeans(data, count, k = 8, iters = 24) {
k = Math.min(k, count);
const random = seededRandom(0);
const centers = new Float64Array(k * 3);
const labels = new Int32Array(count).fill(-1);
const d2 = new Float64Array(count);
function dist2(i, c) {
const p = i * 3;
const q = c * 3;
return (data[p] - centers[q]) ** 2 + (data[p + 1] - centers[q + 1]) ** 2 + (data[p + 2] - centers[q + 2]) ** 2;
}
function copy(i, c) {
const p = i * 3;
const q = c * 3;
centers[q] = data[p];
centers[q + 1] = data[p + 1];
centers[q + 2] = data[p + 2];
}
copy(Math.floor(random() * count), 0);
let total = 0;
for (let i = 0; i < count; i++) {
d2[i] = dist2(i, 0);
total += d2[i];
}
for (let c = 1; c < k; c++) {
let idx = Math.floor(random() * count);
if (total > 0) {
let target = random() * total;
for (let i = 0; i < count; i++) {
target -= d2[i];
if (target <= 0) {
idx = i;
break;
}
}
}
copy(idx, c);
total = 0;
for (let i = 0; i < count; i++) {
d2[i] = Math.min(d2[i], dist2(i, c));
total += d2[i];
}
}
const counts = new Float64Array(k);
const sums = new Float64Array(k * 3);
for (let iter = 0; iter < iters; iter++) {
counts.fill(0);
sums.fill(0);
let changed = false;
for (let i = 0; i < count; i++) {
let best = 0;
let bestD = dist2(i, 0);
for (let c = 1; c < k; c++) {
const d = dist2(i, c);
if (d < bestD) {
best = c;
bestD = d;
}
}
if (labels[i] !== best) {
labels[i] = best;
changed = true;
}
const p = i * 3;
const s = best * 3;
counts[best]++;
sums[s] += data[p];
sums[s + 1] += data[p + 1];
sums[s + 2] += data[p + 2];
}
for (let c = 0; c < k; c++) {
if (!counts[c]) continue;
const s = c * 3;
centers[s] = sums[s] / counts[c];
centers[s + 1] = sums[s + 1] / counts[c];
centers[s + 2] = sums[s + 2] / counts[c];
}
if (!changed && iter > 0) break;
}
return { centers, counts, k };
}
function mergeClusters(centers, counts, k, threshold = 0.10) {
const items = [];
for (let i = 0; i < k; i++) {
if (!counts[i]) continue;
const c = i * 3;
items.push({ L: centers[c], a: centers[c + 1], b: centers[c + 2], count: counts[i] });
}
let changed = true;
while (changed && items.length > 1) {
changed = false;
let best = null;
let bestDistance = threshold;
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const d = Math.hypot(items[i].L - items[j].L, items[i].a - items[j].a, items[i].b - items[j].b);
if (d < bestDistance) {
bestDistance = d;
best = [i, j];
}
}
}
if (!best) continue;
const [i, j] = best;
const left = items[i];
const right = items[j];
const total = left.count + right.count;
items[i] = {
L: (left.L * left.count + right.L * right.count) / total,
a: (left.a * left.count + right.a * right.count) / total,
b: (left.b * left.count + right.b * right.count) / total,
count: total,
};
items.splice(j, 1);
changed = true;
}
return items;
}
function imageToOklabPixels(img) {
const sourceW = img.naturalWidth || img.width;
const sourceH = img.naturalHeight || img.height;
if (!sourceW || !sourceH) return null;
const scale = Math.min(1, 256 / Math.max(sourceW, sourceH));
const width = Math.max(1, Math.round(sourceW * scale));
const height = Math.max(1, Math.round(sourceH * scale));
const canvas = doc.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, width, height);
const rgba = ctx.getImageData(0, 0, width, height).data;
const lab = new Float64Array(width * height * 3);
let count = 0;
for (let i = 0; i < rgba.length; i += 4) {
if (rgba[i + 3] < 16) continue;
const r = srgbToLinear(rgba[i] / 255);
const g = srgbToLinear(rgba[i + 1] / 255);
const b = srgbToLinear(rgba[i + 2] / 255);
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);
const p = count * 3;
lab[p] = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
lab[p + 1] = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
lab[p + 2] = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
count++;
}
return count ? { data: lab.subarray(0, count * 3), count } : null;
}
function extractDominantColor(img) {
try {
const pixels = imageToOklabPixels(img);
if (!pixels) return null;
const { centers, counts, k } = kmeans(pixels.data, pixels.count);
const clusters = mergeClusters(centers, counts, k);
const total = clusters.reduce((sum, cluster) => sum + cluster.count, 0);
let bestIndex = -1;
let bestScore = -1;
let bestPopIndex = 0;
let bestPop = -1;
for (let i = 0; i < clusters.length; i++) {
const { L, a, b, count } = clusters[i];
const C = Math.hypot(a, b);
const pop = count / total;
if (pop > bestPop) {
bestPop = pop;
bestPopIndex = i;
}
const cNorm = Math.min(C / 0.18, 1);
let ext = 1;
if (L > 0.90) ext = 1 - ((L - 0.90) / 0.10) * 0.35;
else if (L < 0.18) ext = 1 - ((0.18 - L) / 0.18) * 0.45;
ext = clamp(ext, 0.5, 1);
const score = pop * (0.40 + 0.90 * cNorm) * ext;
if (score > bestScore) {
bestScore = score;
bestIndex = i;
}
}
const picked = clusters[bestIndex >= 0 ? bestIndex : bestPopIndex];
const C = Math.hypot(picked.a, picked.b) * VIBRANCY_BOOST;
const H = (Math.atan2(picked.b, picked.a) * 180 / Math.PI + 360) % 360;
return oklchToHexGamut(picked.L, C, H);
} catch {
return null;
}
}
function apcaY(hex) {
const [r, g, b] = hexToRgb(hex);
return 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;
}
function apcaLc(textHex, bgHex) {
let Yt = apcaY(textHex);
let Yb = apcaY(bgHex);
if (Yt <= 0.022) Yt += (0.022 - Yt) ** 1.414;
if (Yb <= 0.022) Yb += (0.022 - Yb) ** 1.414;
if (Math.abs(Yb - Yt) < 0.0005) return 0;
if (Yb > Yt) {
const sapc = (Yb ** 0.56 - Yt ** 0.57) * 1.14;
return sapc < 0.1 ? 0 : (sapc - 0.027) * 100;
}
const sapc = (Yb ** 0.65 - Yt ** 0.62) * 1.14;
return sapc > -0.1 ? 0 : (sapc + 0.027) * 100;
}
function applyPalette(sampledHex) {
const scale = buildScale(sampledHex);
const isLight = Math.abs(apcaLc(BLACK, sampledHex)) >= Math.abs(apcaLc(PAPER, sampledHex));
const ordered = [
scale.paper, scale[50], scale[100], scale[150], scale[200],
scale[300], scale[400], scale[500], scale[600], scale[700],
scale[800], scale[850], scale[900], scale[950], scale.black,
];
const baseOrder = isLight ? ordered : [...ordered].reverse();
const baseMap = {
"00": baseOrder[0],
"05": baseOrder[1],
"10": baseOrder[2],
"20": baseOrder[3],
"25": baseOrder[4],
"30": baseOrder[5],
"35": baseOrder[6],
"40": baseOrder[7],
"50": baseOrder[8],
"60": baseOrder[9],
"70": baseOrder[10],
"80": baseOrder[11],
"85": baseOrder[12],
"90": baseOrder[13],
"100": baseOrder[14],
};
const [accentH, accentS, accentL] = rgbToHsl(...hexToRgb(scale[500]));
const root = container.querySelector(".album-view") || container;
root.style.setProperty("--color-bg", sampledHex);
root.style.setProperty("--text-normal", baseMap["100"]);
root.style.setProperty("--text-muted", baseMap["85"]);
root.style.setProperty("--text-faint", baseMap["70"]);
root.style.setProperty("--accent-h", accentH.toFixed(1));
root.style.setProperty("--accent-s", `${accentS.toFixed(1)}%`);
root.style.setProperty("--accent-l", `${accentL.toFixed(1)}%`);
for (const [key, value] of Object.entries(baseMap)) {
root.style.setProperty(`--color-base-${key}`, value.toUpperCase());
}
}
function initPalette() {
const cover = container.querySelector(".album-cover");
if (!cover) {
applyPalette(FALLBACK_COLOR);
return;
}
const run = () => applyPalette(extractDominantColor(cover) || FALLBACK_COLOR);
if (cover.complete && cover.naturalWidth > 0) run();
else {
cover.addEventListener("load", run, { once: true });
cover.addEventListener("error", () => applyPalette(FALLBACK_COLOR), { once: true });
}
}
initPalette();