mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
feat: generate and display thumbhash blur placeholders for TMDB images
This commit is contained in:
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user