-
-
Notifications
You must be signed in to change notification settings - Fork 4
Full Movie view code
Anup Chavan edited this page Jun 21, 2026
·
1 revision
This page contains the full saved Movies view from data.json, with the TMDB API key sanitized.
{
"type": "group",
"operator": "OR",
"conditions": [
{
"type": "filter",
"field": "categories",
"operator": "contains any of",
"value": "[[Movies]]"
},
{
"type": "filter",
"field": "file tags",
"operator": "contains",
"value": "movies"
}
]
}<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>.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);
}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();