"use client";
import {
IconCheck,
IconChevronDown,
IconChevronUp,
IconPlayerPlay,
} from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { TitleDetailSkeleton } from "@/components/skeletons";
import { StarRating } from "@/components/star-rating";
import { StatusButton } from "@/components/status-button";
import { TitleCard } from "@/components/title-card";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Progress } from "@/components/ui/progress";
import { useRegisterShortcut } from "@/hooks/use-register-shortcut";
interface Episode {
id: string;
episodeNumber: number;
name: string | null;
overview: string | null;
airDate: string | null;
runtimeMinutes: number | null;
}
interface Season {
id: string;
seasonNumber: number;
name: string | null;
episodes: Episode[];
}
interface AvailabilityOffer {
providerId: number;
providerName: string;
logoPath: string | null;
offerType: string;
}
interface RecommendedTitle {
id: string;
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
releaseDate: string | null;
firstAirDate: string | null;
voteAverage: number | null;
}
interface Title {
id: string;
tmdbId: number;
type: "movie" | "tv";
title: string;
originalTitle: string | null;
overview: string | null;
releaseDate: string | null;
firstAirDate: string | null;
posterPath: string | null;
backdropPath: string | null;
popularity: number | null;
voteAverage: number | null;
voteCount: number | null;
status: string | null;
seasons: Season[];
availability: AvailabilityOffer[];
userStatus?: string | null;
userRating?: number | null;
episodeWatches?: string[];
}
const staggerContainer = {
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
};
const staggerItem = {
hidden: { opacity: 0, y: 12, scale: 0.98 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring" as const, stiffness: 300, damping: 24 },
},
};
export default function TitleDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [title, setTitle] = useState
(null);
const [recommendations, setRecommendations] = useState(
[],
);
const [loading, setLoading] = useState(true);
const [openSeason, setOpenSeason] = useState(null);
const [watchingEp, setWatchingEp] = useState(null);
const fetchTitle = useCallback(async () => {
setLoading(true);
try {
const [titleRes, statusRes] = await Promise.all([
fetch(`/api/titles/${id}`),
fetch(`/api/titles/${id}/status`),
]);
const titleData = await titleRes.json();
let statusData = { status: null, rating: null, episodeWatches: [] };
if (statusRes.ok) {
statusData = await statusRes.json();
}
setTitle({
...titleData,
userStatus: statusData.status,
userRating: statusData.rating,
episodeWatches: statusData.episodeWatches ?? [],
});
} finally {
setLoading(false);
}
}, [id]);
const fetchRecommendations = useCallback(async () => {
try {
const res = await fetch(`/api/titles/${id}/recommendations`);
if (res.ok) {
const data = await res.json();
setRecommendations(data ?? []);
}
} catch {
// silent
}
}, [id]);
useEffect(() => {
fetchTitle();
fetchRecommendations();
}, [fetchTitle, fetchRecommendations]);
// Keyboard shortcuts
const statusCycle = useMemo(
() => ["watchlist", "in_progress", "completed"] as const,
[],
);
const handleStatusChange = useCallback(
async (status: string | null) => {
// Optimistic update
setTitle((t) => (t ? { ...t, userStatus: status } : t));
try {
const res = await fetch(`/api/titles/${id}/status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
if (!res.ok) throw new Error();
const label =
status === "watchlist"
? "Added to watchlist"
: status === "in_progress"
? "Marked as watching"
: status === "completed"
? "Marked as completed"
: "Removed from list";
toast.success(label);
} catch {
// Revert
setTitle((t) =>
t ? { ...t, userStatus: title?.userStatus ?? null } : t,
);
toast.error("Failed to update status");
}
},
[id, title?.userStatus],
);
const handleRating = useCallback(
async (ratingStars: number) => {
const prev = title?.userRating ?? 0;
// Optimistic update
setTitle((t) => (t ? { ...t, userRating: ratingStars } : t));
try {
const res = await fetch(`/api/titles/${id}/rating`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ratingStars }),
});
if (!res.ok) throw new Error();
toast.success(
ratingStars > 0
? `Rated ${ratingStars} star${ratingStars > 1 ? "s" : ""}`
: "Rating removed",
);
} catch {
setTitle((t) => (t ? { ...t, userRating: prev } : t));
toast.error("Failed to update rating");
}
},
[id, title?.userRating],
);
const handleWatchMovie = useCallback(async () => {
setTitle((t) => (t ? { ...t, userStatus: "completed" } : t));
try {
const res = await fetch(`/api/movies/${id}/watch`, { method: "POST" });
if (!res.ok) throw new Error();
toast.success(`Marked "${title?.title}" as watched`);
} catch {
setTitle((t) =>
t ? { ...t, userStatus: title?.userStatus ?? null } : t,
);
toast.error("Failed to mark as watched");
}
}, [id, title?.title, title?.userStatus]);
const handleWatchEpisode = useCallback(
async (episodeId: string, seasonNum: number, epNum: number) => {
setWatchingEp(episodeId);
// Optimistic update
setTitle((t) => {
if (!t) return t;
const watches = [...(t.episodeWatches ?? [])];
if (!watches.includes(episodeId)) watches.push(episodeId);
return {
...t,
episodeWatches: watches,
userStatus: t.userStatus ?? "in_progress",
};
});
try {
const res = await fetch(`/api/episodes/${episodeId}/watch`, {
method: "POST",
});
if (!res.ok) throw new Error();
toast.success(`Watched S${seasonNum} E${epNum}`);
} catch {
// Revert
setTitle((t) => {
if (!t) return t;
return {
...t,
episodeWatches: (t.episodeWatches ?? []).filter(
(w) => w !== episodeId,
),
};
});
toast.error("Failed to mark episode");
}
setWatchingEp(null);
},
[],
);
// Page keyboard shortcuts
useRegisterShortcut("title-cycle-status", {
keys: ["w"],
description: "Cycle status",
action: () => {
if (!title) return;
const currentIdx = statusCycle.indexOf(
title.userStatus as (typeof statusCycle)[number],
);
const nextStatus =
currentIdx === statusCycle.length - 1
? null
: statusCycle[currentIdx + 1];
handleStatusChange(nextStatus);
},
scope: "Title",
});
useRegisterShortcut("title-mark-watched", {
keys: ["m"],
description: "Mark watched",
action: () => {
if (!title) return;
if (title.type === "movie") {
handleWatchMovie();
}
},
scope: "Title",
});
useRegisterShortcut("title-escape", {
keys: ["Escape"],
description: "Go back",
action: () => router.back(),
scope: "Title",
});
// Rating shortcuts 1-5
for (const n of [1, 2, 3, 4, 5]) {
// biome-ignore lint/correctness/useHookAtTopLevel: loop is stable
useRegisterShortcut(`title-rate-${n}`, {
keys: [String(n)],
description: `Rate ${n} star${n > 1 ? "s" : ""}`,
action: () => handleRating(n),
scope: "Title",
});
}
if (loading) {
return ;
}
if (!title) {
return (
Title not found
);
}
const posterUrl = title.posterPath
? `https://image.tmdb.org/t/p/w500${title.posterPath}`
: null;
const backdropUrl = title.backdropPath
? `https://image.tmdb.org/t/p/w1280${title.backdropPath}`
: null;
const dateStr = title.releaseDate ?? title.firstAirDate;
const year = dateStr?.slice(0, 4);
async function handleMarkSeason(season: Season) {
const unwatched = season.episodes.filter(
(ep) => !title?.episodeWatches?.includes(ep.id),
);
if (unwatched.length === 0) return;
// Optimistic update
setTitle((t) => {
if (!t) return t;
const watches = [...(t.episodeWatches ?? [])];
for (const ep of unwatched) {
if (!watches.includes(ep.id)) watches.push(ep.id);
}
return { ...t, episodeWatches: watches };
});
try {
await Promise.all(
unwatched.map((ep) =>
fetch(`/api/episodes/${ep.id}/watch`, { method: "POST" }),
),
);
toast.success(
`Watched all of ${season.name ?? `Season ${season.seasonNumber}`}`,
);
} catch {
toast.error("Failed to mark some episodes");
}
}
// Group availability by offerType
const availByType: Record = {};
for (const offer of title.availability ?? []) {
if (!availByType[offer.offerType]) availByType[offer.offerType] = [];
availByType[offer.offerType].push(offer);
}
const offerLabels: Record = {
flatrate: "Stream",
rent: "Rent",
buy: "Buy",
free: "Free",
ads: "With Ads",
};
return (
{/* Breadcrumb */}
}>
Home
{title.title}
{/* Backdrop hero */}
{backdropUrl && (
{/* Three-layer gradient */}
{/* Film grain overlay */}
)}
{/* Title header */}
{posterUrl && (
)}
{title.title}
{title.type}
{year && {year}}
{title.voteAverage != null && title.voteAverage > 0 && (
★ {title.voteAverage.toFixed(1)}
{title.voteCount != null && (
({title.voteCount.toLocaleString()})
)}
)}
{title.status && (
{title.status}
)}
{title.overview && (
{title.overview}
)}
{/* Actions */}
{title.type === "movie" && (
)}
Rate:
{/* Availability */}
{Object.keys(availByType).length > 0 && (
Where to Watch
{Object.entries(availByType).map(([type, offers]) => (
{offerLabels[type] ?? type}
{offers.map((offer) => (
))}
))}
)}
{/* Seasons & Episodes (TV) */}
{title.type === "tv" && title.seasons.length > 0 && (
Seasons
{title.seasons.map((season) => {
const isOpen = openSeason === season.seasonNumber;
const watchedCount = season.episodes.filter((ep) =>
title.episodeWatches?.includes(ep.id),
).length;
const totalCount = season.episodes.length;
const progressPercent =
totalCount > 0 ? (watchedCount / totalCount) * 100 : 0;
return (
{isOpen && (
{season.episodes.map((ep) => {
const isWatched = title.episodeWatches?.includes(
ep.id,
);
return (
handleWatchEpisode(
ep.id,
season.seasonNumber,
ep.episodeNumber,
)
}
disabled={watchingEp === ep.id}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border transition-all ${
isWatched
? "border-primary bg-primary text-primary-foreground"
: "border-border/50 hover:border-primary/50 hover:bg-primary/5"
}`}
>
{isWatched && (
)}
E{String(ep.episodeNumber).padStart(2, "0")}
{" "}
{ep.name ?? "Untitled"}
{ep.airDate && (
{ep.airDate}
{ep.runtimeMinutes
? ` · ${ep.runtimeMinutes}m`
: ""}
)}
);
})}
)}
);
})}
)}
{/* Recommendations */}
{recommendations.length > 0 && (
Recommended
{recommendations.slice(0, 12).map((rec) => (
))}
)}
);
}
function ProviderBadge({
name,
logoPath,
}: {
name: string;
logoPath: string | null;
}) {
const logoUrl = logoPath ? `https://image.tmdb.org/t/p/w92${logoPath}` : null;
return (
{logoUrl ? (
) : (
{name.slice(0, 2)}
)}
);
}