150 lines
5.3 KiB
TypeScript
150 lines
5.3 KiB
TypeScript
import { Play } from "lucide-react";
|
|
import { useCallback } from "react";
|
|
import { useLocation } from "react-router";
|
|
import type { WatchTonightItem } from "@/hooks/queries/recommendations";
|
|
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
|
|
import { parseWatchHref } from "@/pages/watchRouteHelpers";
|
|
|
|
const sourceLabels: Record<string, string> = {
|
|
continue_watching: "Continue",
|
|
next_up: "Next Up",
|
|
recommendation: "For You",
|
|
};
|
|
|
|
interface WatchTonightCardProps {
|
|
item: WatchTonightItem;
|
|
onPlay: () => void;
|
|
}
|
|
|
|
export default function WatchTonightCard({ item, onPlay }: WatchTonightCardProps) {
|
|
const location = useLocation();
|
|
const playbackController = useWatchPlaybackController();
|
|
|
|
const watchHref = `/watch/${item.content_id}`;
|
|
const itemHref = `/item/${item.content_id}`;
|
|
const source = item.watch_tonight_source ?? "";
|
|
const isInProgress = source === "continue_watching";
|
|
const isNextUp = source === "next_up";
|
|
const hasEpisodeMeta = item.season_number != null && item.episode_number != null;
|
|
const heading = hasEpisodeMeta && item.series_title ? item.series_title : item.title;
|
|
const progressPercent =
|
|
isInProgress && (item.duration_seconds ?? 0) > 0
|
|
? ((item.position_seconds ?? 0) / (item.duration_seconds ?? 1)) * 100
|
|
: 0;
|
|
|
|
const episodeMeta = hasEpisodeMeta
|
|
? `S${item.season_number} E${item.episode_number}${item.title && item.series_title ? ` \u2022 ${item.title}` : ""}`
|
|
: null;
|
|
|
|
const subtitle = isNextUp
|
|
? "Next Episode"
|
|
: isInProgress && (item.duration_seconds ?? 0) > 0
|
|
? `${Math.max(0, Math.round(((item.duration_seconds ?? 0) - (item.position_seconds ?? 0)) / 60))} min left`
|
|
: item.genres?.slice(0, 2).join(", ") || "\u00A0";
|
|
|
|
const handlePlayClick = useCallback(
|
|
(event: React.MouseEvent<HTMLAnchorElement>) => {
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.button !== 0 ||
|
|
event.metaKey ||
|
|
event.altKey ||
|
|
event.ctrlKey ||
|
|
event.shiftKey
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const parsed = parseWatchHref(watchHref);
|
|
if (!parsed) return;
|
|
|
|
event.preventDefault();
|
|
onPlay();
|
|
playbackController.startPlayback({
|
|
contentId: parsed.contentId,
|
|
fileId: parsed.fileId,
|
|
libraryId: parsed.libraryId,
|
|
restart: parsed.restart,
|
|
returnHref: `${location.pathname}${location.search}`,
|
|
});
|
|
},
|
|
[watchHref, location.pathname, location.search, playbackController, onPlay],
|
|
);
|
|
|
|
const badgeLabel = sourceLabels[source];
|
|
|
|
return (
|
|
<div className="group/card flex gap-5 rounded-xl p-3 transition-colors hover:bg-white/5">
|
|
{/* Thumbnail */}
|
|
<ViewTransitionLink
|
|
to={watchHref}
|
|
onClick={handlePlayClick}
|
|
aria-label={`Play ${heading}`}
|
|
className="group/play relative w-44 shrink-0"
|
|
>
|
|
<div className="relative aspect-video overflow-hidden rounded-lg">
|
|
{item.backdrop_url ? (
|
|
<img
|
|
src={item.backdrop_url}
|
|
alt={heading}
|
|
className="h-full w-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
) : item.poster_url ? (
|
|
<img
|
|
src={item.poster_url}
|
|
alt={heading}
|
|
className="h-full w-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="text-muted-foreground bg-surface flex h-full w-full items-center justify-center text-xs">
|
|
No Image
|
|
</div>
|
|
)}
|
|
|
|
{/* Play overlay */}
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/card:bg-black/30 group-focus-visible/play:bg-black/30">
|
|
<div className="bg-primary text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full opacity-0 shadow-lg transition-opacity group-hover/card:opacity-100 group-focus-visible/play:opacity-100">
|
|
<Play className="ml-0.5 h-4 w-4" fill="currentColor" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
{isInProgress && progressPercent > 0 && (
|
|
<div className="bg-background/40 absolute inset-x-0 bottom-0 h-[3px]">
|
|
<div
|
|
className="h-full transition-all duration-300"
|
|
style={{
|
|
width: `${Math.min(progressPercent, 100)}%`,
|
|
background: "var(--primary)",
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ViewTransitionLink>
|
|
|
|
{/* Info */}
|
|
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
|
|
<div className="flex items-center gap-2">
|
|
{badgeLabel && (
|
|
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
|
{badgeLabel}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<ViewTransitionLink to={itemHref} className="min-w-0">
|
|
<div className="truncate text-[15px] leading-tight font-semibold">{heading}</div>
|
|
</ViewTransitionLink>
|
|
{episodeMeta && (
|
|
<div className="text-muted-foreground truncate text-[13px]">{episodeMeta}</div>
|
|
)}
|
|
<div className="text-muted-foreground text-[13px]">{subtitle}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|