Add dynamic color theming from poster art using node-vibrant

Extract dominant colors from movie/TV poster images server-side and use
them to create per-title atmospheric effects on detail pages — tinted
backdrop gradients, ambient glow orbs, and colored poster shadows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 12:29:13 -05:00
co-authored by Claude Opus 4.6
parent 67356e04c6
commit cb0f366d44
9 changed files with 2481 additions and 6 deletions
+61 -2
View File
@@ -63,6 +63,15 @@ interface RecommendedTitle {
voteAverage: number | null;
}
interface ColorPalette {
vibrant: string | null;
darkVibrant: string | null;
lightVibrant: string | null;
muted: string | null;
darkMuted: string | null;
lightMuted: string | null;
}
interface Title {
id: string;
tmdbId: number;
@@ -78,6 +87,7 @@ interface Title {
voteAverage: number | null;
voteCount: number | null;
status: string | null;
colorPalette: ColorPalette | null;
seasons: Season[];
availability: AvailabilityOffer[];
userStatus?: string | null;
@@ -360,6 +370,7 @@ export default function TitleDetailPage() {
const backdropUrl = tmdbImageUrl(title.backdropPath, "w1280");
const dateStr = title.releaseDate ?? title.firstAirDate;
const year = dateStr?.slice(0, 4);
const palette = title.colorPalette;
async function handleMarkSeason(season: Season) {
const unwatched = season.episodes.filter(
@@ -434,7 +445,7 @@ export default function TitleDetailPage() {
};
return (
<div className="space-y-10">
<div className="relative space-y-10">
{/* Breadcrumb */}
<Breadcrumb className="relative z-20">
<BreadcrumbList>
@@ -465,6 +476,23 @@ export default function TitleDetailPage() {
<div className="absolute inset-0 bg-gradient-to-r from-background/90 via-background/40 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-b from-background/50 via-transparent to-transparent" />
<div className="absolute inset-0 bg-background/15" />
{/* Dynamic color wash from poster palette */}
{palette?.darkMuted && (
<div
className="absolute inset-0 opacity-40 mix-blend-multiply"
style={{
background: `radial-gradient(ellipse at 25% 85%, ${palette.darkMuted} 0%, transparent 65%)`,
}}
/>
)}
{palette?.vibrant && (
<div
className="absolute inset-0 opacity-[0.08]"
style={{
background: `radial-gradient(ellipse at 50% 70%, ${palette.vibrant} 0%, transparent 55%)`,
}}
/>
)}
{/* Film grain overlay */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.03]"
@@ -475,6 +503,30 @@ export default function TitleDetailPage() {
</div>
)}
{/* Ambient glow orbs — large blurred color fields from poster palette */}
{palette && (
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[800px] overflow-hidden">
{palette.vibrant && (
<div
className="absolute -left-32 top-16 h-[500px] w-[500px] rounded-full opacity-[0.07] blur-[120px]"
style={{ background: palette.vibrant }}
/>
)}
{palette.darkMuted && (
<div
className="absolute -right-24 top-48 h-[400px] w-[600px] rounded-full opacity-[0.05] blur-[140px]"
style={{ background: palette.darkMuted }}
/>
)}
{palette.muted && (
<div
className="absolute left-1/3 top-[500px] h-[300px] w-[400px] rounded-full opacity-[0.04] blur-[100px]"
style={{ background: palette.muted }}
/>
)}
</div>
)}
{/* Title header */}
<motion.div
className={`flex flex-col gap-8 sm:flex-row ${backdropUrl ? "-mt-32 relative z-10" : ""}`}
@@ -484,7 +536,14 @@ export default function TitleDetailPage() {
>
{posterUrl && (
<div className="shrink-0">
<div className="overflow-hidden rounded-2xl ring-1 ring-foreground/5 shadow-2xl shadow-black/50">
<div
className="overflow-hidden rounded-2xl ring-1 ring-foreground/5 shadow-2xl transition-shadow duration-500"
style={{
boxShadow: palette?.darkVibrant
? `0 25px 60px -12px ${palette.darkVibrant}50, 0 12px 28px -8px rgba(0,0,0,0.5)`
: "0 25px 50px -12px rgba(0,0,0,0.5)",
}}
>
<Image
src={posterUrl}
alt={title.title}
+7
View File
@@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema";
import { extractAndStoreColors, parseColorPalette } from "@/lib/services/colors";
import { refreshTvChildren } from "@/lib/services/metadata";
import { getTvDetails } from "@/lib/tmdb/client";
@@ -84,8 +85,14 @@ export async function GET(
.where(eq(availabilityOffers.titleId, title.id))
.all();
// Lazy color extraction: if no palette yet, fire-and-forget for next load
if (!title.colorPalette && title.posterPath) {
extractAndStoreColors(title.id, title.posterPath).catch(() => {});
}
return NextResponse.json({
...title,
colorPalette: parseColorPalette(title.colorPalette),
seasons: titleSeasons,
availability,
});