Add watch trailer button with cinematic YouTube player dialog

Fetch trailer video keys from TMDB videos API during title import/refresh,
store in DB, and display a "Trailer" button on title pages that opens a
media-chrome Sutro-themed YouTube player in a fullwidth dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 13:36:38 -05:00
co-authored by Claude Opus 4.6
parent f3a425a5a9
commit 9ecf7bbe3c
12 changed files with 2237 additions and 1 deletions
@@ -2,13 +2,16 @@ import Image from "next/image";
import type { ReactNode } from "react";
import { TmdbLogo } from "@/components/tmdb-logo";
import type { ColorPalette, ResolvedTitle } from "@/lib/types/title";
import { TrailerDialog } from "./trailer-dialog";
export function TitleHero({
title,
trailerVideoKey,
actions,
children,
}: {
title: ResolvedTitle;
trailerVideoKey?: string | null;
actions: ReactNode;
children?: ReactNode;
}) {
@@ -119,6 +122,7 @@ export function TitleHero({
>
<TmdbLogo className="h-2.5 w-auto" />
</a>
{trailerVideoKey && <TrailerDialog videoKey={trailerVideoKey} />}
</div>
</div>
@@ -0,0 +1,58 @@
"use client";
import { IconPlayerPlayFilled } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
export function TrailerDialog({ videoKey }: { videoKey: string }) {
const [open, setOpen] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (open && !loaded) {
import("youtube-video-element");
import("@player.style/sutro");
setLoaded(true);
}
}, [open, loaded]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-5 items-center gap-1 rounded border border-border/50 px-2 text-xs text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<IconPlayerPlayFilled className="h-2.5 w-2.5" />
Trailer
</button>
<DialogContent
showCloseButton={false}
className="sm:max-w-4xl p-0 overflow-hidden bg-black border-white/10"
>
<DialogTitle className="sr-only">Trailer</DialogTitle>
<div className="aspect-video w-full">
{loaded && (
// @ts-expect-error -- web components not in JSX types
<media-theme-sutro
style={{
width: "100%",
height: "100%",
"--media-primary-color": "hsl(35 100% 66%)",
"--media-accent-color": "hsl(35 100% 66%)",
}}
>
{/* @ts-expect-error -- web component */}
<youtube-video
slot="media"
src={`https://www.youtube.com/watch?v=${videoKey}`}
crossorigin=""
/>
{/* @ts-expect-error -- web component */}
</media-theme-sutro>
)}
</div>
</DialogContent>
</Dialog>
);
}
+5 -1
View File
@@ -85,7 +85,11 @@ export default async function TitleDetailPage({
initialEpisodeWatches={userInfo?.episodeWatches ?? []}
seasons={seasons}
>
<TitleHero title={title} actions={<TitleActions />}>
<TitleHero
title={title}
trailerVideoKey={title.trailerVideoKey}
actions={<TitleActions />}
>
<TitleAvailability availability={availability} />
</TitleHero>
+13
View File
@@ -7,6 +7,7 @@
"dependencies": {
"@base-ui/react": "1.2.0",
"@better-auth/drizzle-adapter": "1.5.3",
"@player.style/sutro": "0.2.1",
"@tabler/icons-react": "3.38.0",
"@tanstack/react-hotkeys": "0.3.1",
"better-auth": "1.5.3",
@@ -19,6 +20,7 @@
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.1.0",
"jotai": "2.18.0",
"media-chrome": "4.18.0",
"motion": "12.35.0",
"next": "16.1.6",
"node-vibrant": "4.0.4",
@@ -33,6 +35,7 @@
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"youtube-video-element": "1.9.0",
"zod": "4.3.6",
},
"devDependencies": {
@@ -404,6 +407,8 @@
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
"@player.style/sutro": ["@player.style/sutro@0.2.1", "", { "peerDependencies": { "media-chrome": ">=1.0.0" } }, "sha512-vWtFAdu4z3+1HcDPAriX+41q+bYSOgM898XO4TxEgoNyeaBq/Aoz2cRgWFpsWaK368+n7uWCbO5n2vAszN5fhw=="],
"@prisma/client": ["@prisma/client@7.4.2", "", { "dependencies": { "@prisma/client-runtime-utils": "7.4.2" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA=="],
"@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.4.2", "", {}, "sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q=="],
@@ -652,6 +657,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="],
"ce-la-react": ["ce-la-react@0.3.2", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-QJ6k4lOD/btI08xG8jBPxRCGXvCnusGGkTsiXk0u3NqUu/W+BXRnFD4PYjwtqh8AWmGa5LDbGk0fLQsqr0nSMA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="],
@@ -1094,6 +1101,10 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-chrome": ["media-chrome@4.18.0", "", { "dependencies": { "ce-la-react": "^0.3.2" } }, "sha512-OQmpUCeMMWtUcbar3CiY7V0S6j43HaDhwa6uRUyYiWdfPIj3riWp0agu3x6EcIlDRFIf8iDGZiGeb26ll+mz0g=="],
"media-played-ranges-mixin": ["media-played-ranges-mixin@0.1.0", "", {}, "sha512-zTsvkleu5sAyTsPVxDI+KUbCwy/lXwHgOPi3ER9S3lhtAWhGTQH6qxvfrVMym1cvoLU36SPbVr6Qe8Zxyc0WpA=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
@@ -1500,6 +1511,8 @@
"yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
"youtube-video-element": ["youtube-video-element@1.9.0", "", { "dependencies": { "media-played-ranges-mixin": "^0.1.0" } }, "sha512-Hh0dbQM+FVlUaYUbpYkZNUvdKxTNcSNvTGzkQKYShltnX+LRHEp2eYvC2Zm43eU8Np+CBZuoNR2i+seCYzzAyg=="],
"zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -0,0 +1 @@
ALTER TABLE `titles` ADD `trailerVideoKey` text;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -92,6 +92,7 @@ export const titles = sqliteTable(
voteCount: int("voteCount"),
status: text("status"),
colorPalette: text("colorPalette"),
trailerVideoKey: text("trailerVideoKey"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
+59
View File
@@ -14,8 +14,10 @@ import {
getSimilar,
getTvDetails,
getTvSeasonDetails,
getVideos,
} from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import type { TmdbVideo } from "@/lib/tmdb/types";
import type {
AvailabilityOffer,
Episode,
@@ -150,6 +152,9 @@ export async function importTitle(
log.debug("Color extraction failed:", err),
);
}
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
if (imageCacheEnabled()) {
cacheImagesForTitle(row.id).catch((err) =>
log.debug("Image caching failed:", err),
@@ -204,6 +209,9 @@ export async function importTitle(
log.debug("Color extraction failed:", err),
);
}
refreshTrailer(row.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
if (imageCacheEnabled()) {
cacheImagesForTitle(row.id).catch((err) =>
log.debug("Image caching failed:", err),
@@ -266,6 +274,9 @@ export async function refreshTitle(titleId: string) {
extractAndStoreColors(updated.id, updated.posterPath).catch((err) =>
log.debug("Color extraction failed:", err),
);
refreshTrailer(updated.id).catch((err) =>
log.debug("Trailer enrichment failed:", err),
);
if (imageCacheEnabled()) {
cacheImagesForTitle(updated.id).catch((err) =>
log.debug("Image caching failed:", err),
@@ -657,11 +668,59 @@ export async function getTitleWithChildren(id: string): Promise<{
voteCount: title.voteCount,
status: title.status,
colorPalette: palette,
trailerVideoKey: title.trailerVideoKey,
};
return { title: resolvedTitle, seasons: titleSeasons, availability };
}
export function pickBestTrailer(videos: TmdbVideo[]): string | null {
let candidates = videos.filter((v) => v.site === "YouTube");
if (candidates.length === 0) return null;
// Prefer English, fall back to any language
const english = candidates.filter((v) => v.iso_639_1 === "en");
if (english.length > 0) candidates = english;
const byDate = (a: TmdbVideo, b: TmdbVideo) =>
(b.published_at ?? "").localeCompare(a.published_at ?? "");
// Tier 1: official trailers
const officialTrailers = candidates
.filter((v) => v.official && v.type === "Trailer")
.sort(byDate);
if (officialTrailers.length > 0) return officialTrailers[0].key;
// Tier 2: any trailer
const trailers = candidates.filter((v) => v.type === "Trailer").sort(byDate);
if (trailers.length > 0) return trailers[0].key;
// Tier 3: teasers
const teasers = candidates.filter((v) => v.type === "Teaser").sort(byDate);
if (teasers.length > 0) return teasers[0].key;
return null;
}
export async function refreshTrailer(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
try {
const response = await getVideos(title.tmdbId, title.type);
const key = pickBestTrailer(response.results);
db.update(titles)
.set({ trailerVideoKey: key })
.where(eq(titles.id, titleId))
.run();
log.debug(
`Trailer for "${title.title}": ${key ? `YouTube ${key}` : "none found"}`,
);
} catch (err) {
log.debug(`Failed to fetch trailer for title ${titleId}:`, err);
}
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+5
View File
@@ -7,6 +7,7 @@ import type {
TmdbSearchResponse,
TmdbSeasonDetails,
TmdbTvDetails,
TmdbVideosResponse,
TmdbWatchProviderResponse,
} from "./types";
@@ -141,6 +142,10 @@ export async function discover(
);
}
export async function getVideos(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbVideosResponse>(`/${type}/${tmdbId}/videos`);
}
export async function findByExternalId(
externalId: string,
source: "imdb_id" | "tvdb_id",
+18
View File
@@ -123,6 +123,24 @@ export interface TmdbFindResult {
}[];
}
export interface TmdbVideo {
id: string;
key: string;
name: string;
site: string;
type: string;
official: boolean;
published_at: string;
size: number;
iso_639_1: string;
iso_3166_1: string;
}
export interface TmdbVideosResponse {
id: number;
results: TmdbVideo[];
}
export interface TmdbGenre {
id: number;
name: string;
+1
View File
@@ -58,4 +58,5 @@ export interface ResolvedTitle {
voteCount: number | null;
status: string | null;
colorPalette: ColorPalette | null;
trailerVideoKey: string | null;
}
+3
View File
@@ -17,6 +17,7 @@
"dependencies": {
"@base-ui/react": "1.2.0",
"@better-auth/drizzle-adapter": "1.5.3",
"@player.style/sutro": "0.2.1",
"@tabler/icons-react": "3.38.0",
"@tanstack/react-hotkeys": "0.3.1",
"better-auth": "1.5.3",
@@ -29,6 +30,7 @@
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.1.0",
"jotai": "2.18.0",
"media-chrome": "4.18.0",
"motion": "12.35.0",
"next": "16.1.6",
"node-vibrant": "4.0.4",
@@ -43,6 +45,7 @@
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"youtube-video-element": "1.9.0",
"zod": "4.3.6"
},
"devDependencies": {