Skip to content

Full Movie view code

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

Full Movie view code

This page contains the full saved Movies view from data.json, with the TMDB API key sanitized.

Rules

{
  "type": "group",
  "operator": "OR",
  "conditions": [
    {
      "type": "filter",
      "field": "categories",
      "operator": "contains any of",
      "value": "[[Movies]]"
    },
    {
      "type": "filter",
      "field": "file tags",
      "operator": "contains",
      "value": "movies"
    }
  ]
}

HTML template

<div class="movie-card" data-tmdb-id="{{tmdbId}}">
    <!-- ── Hero: image + gradient overlay ─────────────────────── -->
    <div class="hero">
        <img
            id="bgImg"
            class="hero-img"
            src="{{backdrop|first}}"
            crossorigin="anonymous"
            alt=""
        />
        <div class="hero-gradient" id="gradientOverlay"></div>
    </div>

    <!-- ── Content area (overlaps hero) ──────────────────────── -->
    <div class="content-area">
        <!-- Row 1: movie info (left) + starring/director (right) -->
        <div class="info-row">
            <div class="movie-info">
                <p class="movie-title">{{file.basename}}</p>
                <p class="movie-genre">{{genres|split:","|join:"<span style='color:var(--text-faint)'> · </span>"}}</p>
                <p class="movie-desc">{{description}}</p>
                <div class="meta-row">
                    <p class="movie-meta">
                        {{year}} · {% if (runtime / 60).floor() != 0 %}{{(runtime / 60).floor()}} hr
{% endif %}{{runtime % 60}} min · {{rating}}
                    </p>
                </div>
            </div>

            <div class="starring-col">
                <div class="starring-row">
                    <p class="starring-label">Starring</p>
                    <p class="starring-value">{{cast|slice:0,4|split:","|join:", "}}</p>
                </div>
                <div class="starring-row">
                    <p class="starring-label">Director</p>
                    <p class="starring-value">
                        {{directors|split:","|join:", "}}
                    </p>
                </div>
            </div>
        </div>

        <!-- Row 2: content/watch + trailers + cast sections -->
        <div class="bottom-section">
            <!-- file.content + Watch Now / 4K -->
            <div class="content-watch-row">
                <div class="file-content-wrap">
                    {{file.content}}
                </div>
                <div class="watch-col">
                    <div class="watch-row">
                        <p class="watch-label">Watch Now</p>
                        <div class="badge-4k">
                            <div class="badge-4k-bg"></div>
                            <span class="badge-4k-text">4K</span>
                        </div>
                    </div>
                </div>
            </div>

            <!-- Trailers -->
            <div class="trailers-section">
                <p class="section-heading">Trailers</p>
                <div class="trailers-row" id="trailersRow">
                    <span class="tmdb-loading">Loading trailers…</span>
                </div>
            </div>

            <!-- Cast and Crew -->
            <div class="cast-section">
                <p class="section-heading">Cast and Crew</p>
                <div class="cast-row" id="castRow">
                    <div class="cast-inner">
                        <span class="tmdb-loading">Loading cast…</span>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

CSS

.obsidian-custom-view-editable .obsidian-custom-view-render, .obsidian-custom-view-render {
    padding: 0 !important;
}
.file-content-wrap .markdown-rendered-content.markdown-preview-view.markdown-rendered {
    margin: 0;
    padding: 0;
} 

blockquote {
        color: var(--blockquote-color);
    font-style: var(--blockquote-font-style);
    background-color: var(--blockquote-background-color);
    border-inline-start: var(--blockquote-border-thickness) solid var(--blockquote-border-color);
    padding-top: 0;
    padding-bottom: 0;
    padding-inline-start: var(--size-4-6);
    margin-inline-start: 0;
    margin-inline-end: 0;
}
.cm-content.cm-lineWrapping {
    padding-bottom: 40px !important;
}
/* ================================================================
   Movie Card — Obsidian Custom View
   Faithful translation of Figma node 1:2
   ================================================================ */
.obsidian-custom-view-render, .obsidian-custom-view-editable .obsidian-custom-view-render {
    padding: 0;
}


.movie-card {
  background-color: var(--color-bg, #7baaba);
  position: relative;
  width: 100%;
  padding-bottom: 145px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 10px;
  align-items: flex-start;
  caret-color: pink;
}

/* ── Hero ──────────────────────────────────────────────────────── */
.hero {
  position: absolute;
  width: 100%;
  flex-shrink: 0;
  line-height: 0;
}

.hero-img {
  display: block;
  width: 100%;
  aspect-ratio: 3840 / 2160;
  object-fit: cover;
  max-width: none;
}

/* Gradient overlay.
   Figma: div starting at mt-372px, h-600px, inside 972px hero.
   Stop 1 (transparent): 372 + 600×23.5%  = 513px from hero top = 52.77% of 972
   Stop 2 (solid):       372 + 600×53.08% = 690.5px            = 71.04% of 972
   Applied as inset-0 so % are of the image height at any width. */
.hero-gradient {
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom,
    transparent 52.77%,
    var(--color-bg, #7baaba) 71.04%
  );
  pointer-events: none;
}

/* ── Content area ──────────────────────────────────────────────── */
/* Figma: absolute top-583px over a 972px hero.
   HTML: negative margin = 583px - (image height as % of width).
   Image aspect 3840/2160 → height = width × 56.25%.
   So margin-top: calc(583px - 56.25%) replicates the overlap at any width. */
.content-area {
  margin-top: calc(100% * (6/16));
  position: relative;
  z-index: 1;
  width: 100%;
  box-sizing: border-box;
  padding: 0 100px 10px;
  display: flex;
  flex-direction: column;
  gap: 20px;
  align-items: flex-start;
}

/* ── Info row ──────────────────────────────────────────────────── */
.info-row {
  display: flex;
  flex-wrap: wrap;
  row-gap: 20px;
  column-gap: 30px;
  align-items: flex-end;
  justify-content: space-between;
  flex-shrink: 0;
  width: 100%;
}

/* Left: title / genre / description / meta */
.movie-info {
  display: flex;
  flex: 1 0 0;
  flex-direction: column;
  gap: 12px;
  align-items: flex-start;
  max-width: 900px;
  min-width: 800px;
  overflow: clip;
}

.movie-title {
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro', sans-serif;
  font-size: calc(36px + 1.5vw);
  font-weight: 500;
  line-height: normal;
  color: var(--text-normal);
  letter-spacing: -1.28px;
  width: 100%;
  margin: 0;
  word-break: break-word;
}

.movie-genre {
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 24px;
  font-weight: 510;
  line-height: normal;
  color: var(--text-muted);
  letter-spacing: -0.48px;
  width: 100%;
  margin: 0;
  word-break: break-word;
}

.movie-genre a {
    text-decoration: none;
    color: var(--text-muted);
}
.movie-genre a:hover {
    text-decoration: none;
    color: var(--color-base-80);
}

.movie-desc {
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 18px;
  font-weight: 400;
  line-height: 1.6;
  color: var(--text-muted);
  letter-spacing: -0.36px;
  width: 100%;
  margin: 0;
  word-break: break-word;
}

.meta-row {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 100%;
}

.movie-meta {
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 18px;
  font-weight: 400;
  line-height: normal;
  color: var(--text-faint);
  letter-spacing: -0.36px;
  flex: 1 0 0;
  min-width: 1px;
  margin: 0;
  word-break: break-word;
}

/* Right: Starring / Director */
.starring-col {
  display: flex;
  flex: 1 0 0;
  flex-direction: column;
  align-items: flex-start;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 16px;
  font-weight: 400;
  line-height: 1.6;
  letter-spacing: -0.32px;
  max-width: 400px;
  min-width: 400px;
  overflow: clip;
  padding-top: 29px;
}

.starring-row {
  display: flex;
  gap: 10px;
  align-items: flex-start;
  flex-shrink: 0;
  width: 100%;
}

.starring-label {
  color: var(--text-faint);
  white-space: nowrap;
  flex-shrink: 0;
  margin: 0;
}

.starring-value {
  flex: 1 0 0;
  min-width: 1px;
  color: var(--text-muted);
  margin: 0;
}

.starring-value a {
    text-decoration: none;
    color: var(--text-normal);
}

/* ── Bottom section ────────────────────────────────────────────── */
.bottom-section {
  display: flex;
  flex-direction: column;
  gap: 20px;
  align-items: flex-start;
  padding-top: 30px;
  flex-shrink: 0;
  width: 100%;
}

/* Content + Watch Now row */
.content-watch-row {
  display: flex;
  gap: 30px;
  align-items: flex-start;
  flex-shrink: 0;
  width: 100%;
    justify-content: space-between;
}

.file-content-wrap {

  flex-shrink: 0;
    flex-grow: 1;
  width: auto;
    max-width: 700px;
}

.file-content-text {
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 18px;
  font-weight: 400;
  line-height: 1.6;
  color: var(--text-muted);
  letter-spacing: -0.36px;
  flex: 1 0 0;
  min-width: 1px;
  margin: 0;
  word-break: break-word;
}

.watch-col {
  display: flex;
  flex: 1 0 0;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-width: 1px;
    max-width: 400px;
}

.watch-row {
  display: flex;
  gap: 10px;
  align-items: center;
  flex-shrink: 0;
  width: 100%;
}

.watch-label {
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 22px;
  font-weight: 400;
  line-height: 1.6;
  color: var(--text-normal);
  letter-spacing: -0.44px;
  flex-shrink: 0;
  white-space: nowrap;
  margin: 0;
}

/* 4K badge: CSS grid stack (bg rect + text overlapping) */
.badge-4k {
  display: inline-grid;
  grid-template-columns: max-content;
  grid-template-rows: max-content;
  line-height: 0;
  place-items: start;
  flex-shrink: 0;
}

.badge-4k-bg {
  grid-column: 1;
  grid-row: 1;
  background-color: var(--color-base-30);
  height: 27px;
  width: 44px;
  border-radius: 7px;
}

.badge-4k-text {
  grid-column: 1;
  grid-row: 1;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 14px;
  font-weight: 510;
  line-height: 1.6;
  color: var(--text-muted);
  letter-spacing: -0.28px;
  white-space: nowrap;
  margin-left: 13px;
  margin-top: 3px;
}

/* ── Trailers ──────────────────────────────────────────────────── */
.trailers-section {
  display: flex;
  flex-direction: column;
  gap: 10px;
  align-items: flex-start;
  flex-shrink: 0;
  width: 100%;
}

.section-heading {
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 24px;
  font-weight: 400;
  line-height: 1.6;
  color: var(--text-normal);
  letter-spacing: -0.48px;
  white-space: nowrap;
  flex-shrink: 0;
  margin: 0;
}

.trailers-row {
  display: flex;
  gap: 20px;
    width: 100%;
    overflow: scroll;
  align-items: flex-start;
  flex-shrink: 0;
}

.trailer-card {
  position: relative;
  height: 159px;
  width: 282px;
  border-radius: 8px;
  flex-shrink: 0;
  overflow: hidden;
  cursor: pointer;
}

.trailer-thumb {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  max-width: none;
}

.trailer-dark-overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.2);
  pointer-events: none;
}

/* ── Cast and Crew ─────────────────────────────────────────────── */
.cast-section {
  display: flex;
  flex-direction: column;
  gap: 10px;
  align-items: flex-start;
  flex-shrink: 0;
  width: 100%;
}

.cast-row {
  display: flex;
  align-items: flex-start;
  flex-shrink: 0;
  width: 100%;
    overflow: scroll;
}

.cast-inner {
  display: flex;
  flex: 1 0 0;
  gap: 20px;
  align-items: flex-start;
  min-width: 1px;
}

.cast-card {
  display: flex;
  flex-direction: column;
  gap: 16px;
  align-items: flex-start;
  flex-shrink: 0;
}

.cast-portrait {
  height: 179px;
  width: 120px;
  border-radius: 8px;
  object-fit: cover;
  display: block;
  flex-shrink: 0;
}

.cast-avatar {
  height: 179px;
  width: 120px;
  border-radius: 8px;
  background-color: var(--color-base-20, #2a2a2a);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-faint);
  font-size: 36px;
  font-weight: 300;
  flex-shrink: 0;
}

.cast-info {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  line-height: 1.6;
  flex-shrink: 0;
  width: 120px;
  word-break: break-word;
}

.cast-name {
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 16px;
  font-weight: 400;
  letter-spacing: -0.32px;
  color: var(--text-normal);
  width: 100%;
  margin: 0;
}

.cast-role {
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif;
  font-size: 14px;
  font-weight: 400;
  font-style: italic;
  letter-spacing: -0.28px;
  color: var(--text-muted);
  width: 100%;
  margin: 0;
}

.tmdb-loading {
  font-family: -apple-system, sans-serif;
  font-size: 14px;
  color: var(--text-faint);
}

JavaScript

const container = this;
const doc = container.ownerDocument;
const win = doc.defaultView ?? window;

const TMDB_API_KEY = "YOUR_TMDB_API_KEY";
const TMDB_ID = container.querySelector(".movie-card")?.dataset.tmdbId ?? "";
const TMDB_BASE = "https://api.themoviedb.org/3";
const TMDB_IMG = "https://image.tmdb.org/t/p";

const FALLBACK_COLOR = "#7baaba";
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("#", "").trim();
	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 b2 = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
	const C = Math.hypot(a, b2);
	const H = (Math.atan2(b2, a) * 180 / Math.PI + 360) % 360;

	return [L, C, H];
}

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 oklabDistance2(data, pointIndex, centers, centerIndex) {
	const p = pointIndex * 3;
	const c = centerIndex * 3;
	const dL = data[p] - centers[c];
	const da = data[p + 1] - centers[c + 1];
	const db = data[p + 2] - centers[c + 2];
	return dL * dL + da * da + db * db;
}

function copyPointToCenter(data, pointIndex, centers, centerIndex) {
	const p = pointIndex * 3;
	const c = centerIndex * 3;
	centers[c] = data[p];
	centers[c + 1] = data[p + 1];
	centers[c + 2] = data[p + 2];
}

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

	copyPointToCenter(data, Math.floor(random() * count), centers, 0);

	let total = 0;
	for (let i = 0; i < count; i++) {
		d2[i] = oklabDistance2(data, i, centers, 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;
				}
			}
		}

		copyPointToCenter(data, idx, centers, c);

		total = 0;
		for (let i = 0; i < count; i++) {
			d2[i] = Math.min(d2[i], oklabDistance2(data, i, centers, 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 = oklabDistance2(data, i, centers, 0);

			for (let c = 1; c < k; c++) {
				const d = oklabDistance2(data, i, centers, 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, 8, 24);
		const clusters = mergeClusters(centers, counts, k, 0.10);

		const total = clusters.reduce((sum, cluster) => sum + cluster.count, 0);
		if (!total) return null;

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

	const blackThreshold = 0.022;
	const blackClamp = 1.414;

	if (Yt <= blackThreshold) Yt += (blackThreshold - Yt) ** blackClamp;
	if (Yb <= blackThreshold) Yb += (blackThreshold - Yb) ** blackClamp;

	if (Math.abs(Yb - Yt) < 0.0005) return 0;

	if (Yb > Yt) {
		const contrast = (Yb ** 0.56 - Yt ** 0.57) * 1.14;
		return contrast < 0.1 ? 0 : (contrast - 0.027) * 100;
	}

	const contrast = (Yb ** 0.65 - Yt ** 0.62) * 1.14;
	return contrast > -0.1 ? 0 : (contrast + 0.027) * 100;
}

function applyPalette(sampledHex) {
	const scale = buildScale(sampledHex);
	const isLight = Math.abs(apcaLc(BLACK, sampledHex)) >= Math.abs(apcaLc(PAPER, sampledHex));
	const [r, g, b] = hexToRgb(sampledHex).map((v) => Math.round(v * 255));

	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 baseVars = Object.entries(baseMap)
		.map(([key, value]) => `    --color-base-${key}: ${value.toUpperCase()};`)
		.join("\n");

	const textNormal = baseMap["100"];
	const textMuted = baseMap["85"];
	const textFaint = baseMap["70"];
	const bgPrimary = baseMap["00"];
	const bgSecond = baseMap["05"];
	const bgHover = baseMap["10"];
	const border = isLight ? baseMap["30"] : baseMap["70"];
	const borderHover = isLight ? baseMap["35"] : baseMap["60"];
	const borderFocus = isLight ? baseMap["40"] : baseMap["60"];
	const selector = ".obsidian-custom-view-render, .obsidian-custom-view-editable .obsidian-custom-view-render";

	const css = `
${selector} {
    --color-bg: ${sampledHex};
    --color-gradient-start: rgba(${r}, ${g}, ${b}, 0);
    --accent-h: ${accentH.toFixed(1)};
    --accent-s: ${accentS.toFixed(1)}%;
    --accent-l: ${accentL.toFixed(1)}%;
${baseVars}
    --background-primary: ${sampledHex};
    --background-secondary: ${bgSecond};
    --background-modifier-border: ${border};
    --background-modifier-border-hover: ${borderHover};
    --background-modifier-border-focus: ${borderFocus};
    --background-modifier-hover: ${bgHover};
    --background-modifier-form-field: ${bgHover};
    --table-border-color: ${border};
    --text-normal: ${textNormal};
    --text-muted: ${textMuted};
    --text-faint: ${textFaint};
    --blockquote-border-color: ${textFaint};
    --interactive-accent: ${textFaint};
    --text-on-accent: ${isLight ? scale.black : scale.paper};
    --text-on-accent-inverted: ${isLight ? scale.paper : scale.black};
    color: ${textNormal};
    background-color: ${bgPrimary};
}

${selector} .theme-light,
${selector} .theme-dark {
${baseVars}
    --accent-h: ${accentH.toFixed(1)};
    --accent-s: ${accentS.toFixed(1)}%;
    --accent-l: ${accentL.toFixed(1)}%;
    --text-normal: ${textNormal};
    --text-muted: ${textMuted};
    --text-faint: ${textFaint};
    --link-color: ${textNormal};
    --background-primary: ${bgPrimary};
    --background-secondary: ${bgSecond};
    --bases-table-header-background: ${sampledHex};
    --bases-table-header-color: ${textMuted};
    --bases-embed-border-color: ${textMuted};
    --icon-color-active: ${textNormal};
    --caret-color: ${textMuted};
}`;

	let styleEl = container.querySelector("#flexoki-palette");
	if (!styleEl) {
		styleEl = doc.createElement("style");
		styleEl.id = "flexoki-palette";
		container.appendChild(styleEl);
	}
	styleEl.textContent = css;

	const gradientEl = container.querySelector("#gradientOverlay");
	if (gradientEl) {
		gradientEl.style.background = `linear-gradient(to bottom, transparent 32.77%, ${sampledHex} 83.04%)`;
	}
}

function loadPaletteFromImage() {
	const bgImg = container.querySelector("#bgImg");

	if (!bgImg) {
		applyPalette(FALLBACK_COLOR);
		return;
	}

	if (!bgImg.crossOrigin) bgImg.crossOrigin = "anonymous";

	const applyFromLoadedImage = () => {
		applyPalette(extractDominantColor(bgImg) ?? FALLBACK_COLOR);
	};

	if (bgImg.complete && bgImg.naturalWidth > 0) {
		applyFromLoadedImage();
	} else {
		bgImg.addEventListener("load", applyFromLoadedImage, { once: true });
		bgImg.addEventListener("error", () => applyPalette(FALLBACK_COLOR), { once: true });
	}
}

function tmdbReady() {
	return Boolean(TMDB_ID && TMDB_API_KEY);
}

async function fetchTmdb(path) {
	const separator = path.includes("?") ? "&" : "?";
	const res = await fetch(`${TMDB_BASE}${path}${separator}api_key=${TMDB_API_KEY}`);

	if (!res.ok) throw new Error(`HTTP ${res.status}`);
	return res.json();
}

function setRowMessage(el, text) {
	if (!el) return;

	const span = doc.createElement("span");
	span.className = "tmdb-loading";
	span.textContent = text;
	el.replaceChildren(span);
}

function makeAvatar(name) {
	const el = doc.createElement("div");
	el.className = "cast-avatar";
	el.textContent = (name || "?")[0].toUpperCase();
	return el;
}

function appendCastCard(parent, person) {
	const card = doc.createElement("div");
	card.className = "cast-card";

	if (person.photo) {
		const img = doc.createElement("img");
		img.className = "cast-portrait";
		img.src = `${TMDB_IMG}/w185${person.photo}`;
		img.alt = person.name;
		img.loading = "lazy";
		img.onerror = () => img.replaceWith(makeAvatar(person.name));
		card.appendChild(img);
	} else {
		card.appendChild(makeAvatar(person.name));
	}

	const info = doc.createElement("div");
	info.className = "cast-info";

	const name = doc.createElement("p");
	name.className = "cast-name";
	name.textContent = person.name;

	const role = doc.createElement("p");
	role.className = "cast-role";
	role.textContent = person.role || "";

	info.append(name, role);
	card.appendChild(info);
	parent.appendChild(card);
}

async function loadCast() {
	const inner = container.querySelector("#castRow .cast-inner") ?? container.querySelector("#castRow");
	if (!inner) return;

	if (!tmdbReady()) {
		setRowMessage(inner, "Set tmdbId and tmdbApiKey in frontmatter.");
		return;
	}

	try {
		const { cast = [], crew = [] } = await fetchTmdb(`/movie/${TMDB_ID}/credits`);
		const keyJobs = new Set(["Director", "Director of Photography", "Original Music Composer", "Producer"]);

		const people = [
			...cast.slice(0, 10).map((p) => ({
				name: p.name,
				role: p.character,
				photo: p.profile_path,
			})),
			...crew
				.filter((p) => keyJobs.has(p.job))
				.slice(0, 5)
				.map((p) => ({
					name: p.name,
					role: p.job,
					photo: p.profile_path,
				})),
		];

		inner.replaceChildren();
		for (const person of people) appendCastCard(inner, person);
	} catch (e) {
		setRowMessage(inner, `Could not load cast (${e.message}).`);
	}
}

async function loadTrailers() {
	const row = container.querySelector("#trailersRow");
	if (!row) return;

	if (!tmdbReady()) {
		setRowMessage(row, "Set tmdbId and tmdbApiKey in frontmatter.");
		return;
	}

	try {
		const { results = [] } = await fetchTmdb(`/movie/${TMDB_ID}/videos`);
		const trailers = results
			.filter((v) => v.site === "YouTube" && ["Trailer", "Teaser"].includes(v.type))
			.slice(0, 4);

		if (!trailers.length) {
			setRowMessage(row, "No trailers available.");
			return;
		}

		row.replaceChildren();

		for (const trailer of trailers) {
			const card = doc.createElement("div");
			card.className = "trailer-card";

			const img = doc.createElement("img");
			img.className = "trailer-thumb";
			img.src = `https://img.youtube.com/vi/${trailer.key}/hqdefault.jpg`;
			img.alt = trailer.name;
			img.loading = "lazy";

			const overlay = doc.createElement("div");
			overlay.className = "trailer-dark-overlay";

			card.append(img, overlay);
			card.addEventListener("click", () => {
				win.open(`https://www.youtube.com/watch?v=${trailer.key}`, "_blank", "noopener");
			});

			row.appendChild(card);
		}
	} catch (e) {
		setRowMessage(row, `Could not load trailers (${e.message}).`);
	}
}

loadPaletteFromImage();
void loadCast();
void loadTrailers();

Wiki pages

Clone this wiki locally