Skip to content

Full Album view code

Anup Chavan edited this page Jun 21, 2026 · 1 revision

Full Album view code

This page contains the full saved Albums view from data.json.

Rules

{
  "type": "group",
  "operator": "AND",
  "conditions": [
    {
      "type": "filter",
      "field": "categories",
      "operator": "contains",
      "value": "[[Albums]]"
    }
  ]
}

HTML template

{% 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>

CSS

.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;
}

JavaScript

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

Wiki pages

Clone this wiki locally