feat: generate and display thumbhash blur placeholders for TMDB images

This commit is contained in:
2026-03-14 18:58:09 -04:00
parent 76648a6023
commit aede4fc90a
49 changed files with 4294 additions and 287 deletions
@@ -1,18 +1,21 @@
import { IconPlayerPlay } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";
import { thumbHashToUrl } from "@/lib/thumbhash";
export interface ContinueWatchingItemProps {
title: {
id: string;
title: string;
backdropPath: string | null;
backdropThumbHash?: string | null;
};
nextEpisode: {
seasonNumber: number;
episodeNumber: number;
name: string | null;
stillPath: string | null;
stillThumbHash?: string | null;
} | null;
totalEpisodes: number;
watchedEpisodes: number;
@@ -36,7 +39,17 @@ export function ContinueWatchingCard({
params={{ id: item.title.id }}
className="group relative inline-block w-64 shrink-0 overflow-hidden rounded-xl bg-card/50 ring-1 ring-white/[0.06] transition-shadow hover:shadow-black/25 hover:shadow-lg sm:w-72"
>
<div className="relative aspect-video overflow-hidden rounded-t-xl bg-muted">
<div
className="relative aspect-video overflow-hidden rounded-t-xl bg-muted"
style={(() => {
const hash =
item.nextEpisode?.stillThumbHash ?? item.title.backdropThumbHash;
const url = thumbHashToUrl(hash);
return url
? { backgroundImage: `url(${url})`, backgroundSize: "cover" }
: undefined;
})()}
>
{stillUrl ? (
<img
src={stillUrl}
@@ -7,6 +7,7 @@ interface TitleGridItem {
type: string;
title: string;
posterPath: string | null;
posterThumbHash?: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
voteAverage?: number | null;
@@ -46,6 +47,7 @@ export function TitleGrid({ items }: { items: TitleGridItem[] }) {
type={t.type}
title={t.title}
posterPath={t.posterPath}
posterThumbHash={t.posterThumbHash}
releaseDate={t.releaseDate ?? t.firstAirDate}
voteAverage={t.voteAverage}
userStatus={t.userStatus}
@@ -15,6 +15,7 @@ interface TitleRowItem {
type: "movie" | "tv";
title: string;
posterPath: string | null;
posterThumbHash?: string | null;
releaseDate: string | null;
firstAirDate: string | null;
voteAverage: number | null;
@@ -141,6 +142,7 @@ export function FilterableTitleRow({
type={item.type}
title={item.title}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses[`${item.tmdbId}-${item.type}`]}
@@ -6,6 +6,7 @@ interface TitleRowItem {
type: "movie" | "tv";
title: string;
posterPath: string | null;
posterThumbHash?: string | null;
releaseDate: string | null;
firstAirDate: string | null;
voteAverage: number | null;
@@ -52,6 +53,7 @@ export function TitleRow({
type={item.type}
title={item.title}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={userStatuses?.[`${item.tmdbId}-${item.type}`]}
@@ -129,6 +129,7 @@ export function FilmographyGrid({
type={credit.type}
title={credit.title}
posterPath={credit.posterPath}
posterThumbHash={credit.posterThumbHash}
releaseDate={credit.releaseDate ?? credit.firstAirDate}
voteAverage={credit.voteAverage}
userStatus={userStatuses?.[credit.titleId]}
+12 -1
View File
@@ -4,6 +4,7 @@ import { format, parseISO } from "date-fns";
import { ExpandableText } from "@/components/expandable-text";
import { Badge } from "@/components/ui/badge";
import { thumbHashToUrl } from "@/lib/thumbhash";
interface PersonHeroProps {
person: ResolvedPerson;
@@ -27,7 +28,17 @@ export function PersonHero({ person }: PersonHeroProps) {
return (
<div className="flex animate-stagger-item flex-col gap-6 sm:flex-row sm:gap-8">
<div className="size-40 shrink-0 self-center overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/10 sm:size-56 sm:self-start">
<div
className="size-40 shrink-0 self-center overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/10 sm:size-56 sm:self-start"
style={
person.profileThumbHash
? {
backgroundImage: `url(${thumbHashToUrl(person.profileThumbHash)})`,
backgroundSize: "cover",
}
: undefined
}
>
{person.profilePath ? (
<img
src={person.profilePath}
+17 -1
View File
@@ -22,6 +22,7 @@ import {
} from "@/components/ui/tooltip";
import { useTiltEffect } from "@/hooks/use-tilt-effect";
import { orpc } from "@/lib/orpc/client";
import { thumbHashToUrl } from "@/lib/thumbhash";
export function TitleCardSkeleton() {
return (
@@ -47,6 +48,7 @@ interface CardInnerProps {
title: string;
type: string;
posterPath: string | null;
posterThumbHash?: string | null;
releaseDate?: string | null;
voteAverage?: number | null;
userStatus?: TitleStatus | null;
@@ -169,6 +171,7 @@ function CardInner({
title,
type,
posterPath,
posterThumbHash,
releaseDate,
voteAverage,
userStatus,
@@ -177,6 +180,7 @@ function CardInner({
}: CardInnerProps) {
const year = releaseDate?.slice(0, 4);
const TypeIcon = type === "movie" ? IconMovie : IconDeviceTv;
const placeholderUrl = thumbHashToUrl(posterThumbHash);
const ringClass = userStatus
? "ring-primary/25 shadow-sm shadow-primary/5"
@@ -186,7 +190,17 @@ function CardInner({
<div
className={`relative overflow-hidden rounded-xl bg-card ring-1 transition-[box-shadow,ring-color] duration-200 ease-out hover:shadow-lg hover:shadow-primary/5 hover:ring-primary/25 ${ringClass}`}
>
<div className="aspect-[2/3] overflow-hidden bg-card">
<div
className="aspect-[2/3] overflow-hidden bg-card"
style={
placeholderUrl
? {
backgroundImage: `url(${placeholderUrl})`,
backgroundSize: "cover",
}
: undefined
}
>
{posterPath ? (
<motion.div style={tiltStyles?.imageStyle}>
<img
@@ -282,6 +296,7 @@ export function TitleCard({
type,
title,
posterPath,
posterThumbHash,
releaseDate,
voteAverage,
userStatus,
@@ -310,6 +325,7 @@ export function TitleCard({
title={title}
type={type}
posterPath={posterPath}
posterThumbHash={posterThumbHash}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
@@ -1,8 +1,9 @@
import type { CastMember } from "@sofa/api/schemas";
import { IconUser, IconUsers } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";
import { ScrollArea } from "@/components/ui/scroll-area";
import { thumbHashToUrl } from "@/lib/thumbhash";
interface CastCarouselProps {
actors: CastMember[];
@@ -31,7 +32,17 @@ export function CastCarousel({ actors, titleType }: CastCarouselProps) {
params={{ id: member.personId }}
className="group flex flex-col items-center gap-2"
>
<div className="size-20 overflow-hidden rounded-full ring-1 ring-white/10 transition-all group-hover:ring-primary/25 sm:size-24">
<div
className="size-20 overflow-hidden rounded-full ring-1 ring-white/10 transition-all group-hover:ring-primary/25 sm:size-24"
style={
member.profileThumbHash
? {
backgroundImage: `url(${thumbHashToUrl(member.profileThumbHash)})`,
backgroundSize: "cover",
}
: undefined
}
>
{member.profilePath ? (
<img
src={member.profilePath}
+19 -2
View File
@@ -9,10 +9,11 @@ import {
IconRefresh,
IconStarFilled,
} from "@tabler/icons-react";
import type { ReactNode } from "react";
import { ExpandableText } from "@/components/expandable-text";
import { TmdbLogo } from "@/components/tmdb-logo";
import { thumbHashToUrl } from "@/lib/thumbhash";
import { GenreCollapse } from "./genre-collapse";
import { TrailerDialog } from "./trailer-dialog";
@@ -35,7 +36,17 @@ export function TitleHero({
<>
{/* Backdrop hero */}
{title.backdropPath && (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 overflow-hidden md:h-[28rem]">
<div
className="relative -mt-6 mr-[calc(-50vw+50%)] ml-[calc(-50vw+50%)] h-80 overflow-hidden md:h-[28rem]"
style={
title.backdropThumbHash
? {
backgroundImage: `url(${thumbHashToUrl(title.backdropThumbHash)})`,
backgroundSize: "cover",
}
: undefined
}
>
<img
src={title.backdropPath}
alt=""
@@ -92,6 +103,12 @@ export function TitleHero({
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)",
...(title.posterThumbHash
? {
backgroundImage: `url(${thumbHashToUrl(title.posterThumbHash)})`,
backgroundSize: "cover",
}
: {}),
}}
>
<img
@@ -50,6 +50,7 @@ export function TitleRecommendations({ titleId }: { titleId: string }) {
type={rec.type}
title={rec.title}
posterPath={rec.posterPath}
posterThumbHash={rec.posterThumbHash}
releaseDate={rec.releaseDate ?? rec.firstAirDate}
voteAverage={rec.voteAverage}
userStatus={data.userStatuses[rec.id]}
+9
View File
@@ -0,0 +1,9 @@
import { thumbHashToDataURL } from "thumbhash";
export function thumbHashToUrl(
hash: string | null | undefined,
): string | undefined {
if (!hash) return undefined;
const binary = Uint8Array.from(atob(hash), (c) => c.charCodeAt(0));
return thumbHashToDataURL(binary);
}