mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user