mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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) => [
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,4 +58,5 @@ export interface ResolvedTitle {
|
||||
voteCount: number | null;
|
||||
status: string | null;
|
||||
colorPalette: ColorPalette | null;
|
||||
trailerVideoKey: string | null;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user