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
View File
@@ -27,7 +27,6 @@
"@react-navigation/elements": "2.9.10",
"@shopify/flash-list": "2.3.0",
"@sofa/api": "workspace:*",
"@sofa/tmdb": "workspace:*",
"@tabler/icons-react-native": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.24",
"@tanstack/react-form": "1.28.5",
+2
View File
@@ -91,6 +91,7 @@ export default function PersonDetailScreen() {
title={credit.title}
type={credit.type}
posterPath={credit.posterPath}
posterThumbHash={credit.posterThumbHash}
releaseDate={credit.releaseDate ?? credit.firstAirDate}
voteAverage={credit.voteAverage}
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
@@ -189,6 +190,7 @@ export default function PersonDetailScreen() {
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
thumbHash={person.profileThumbHash}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
/>
+4
View File
@@ -165,6 +165,7 @@ export default function TitleDetailScreen() {
title: item.title,
type: item.type,
posterPath: item.posterPath,
posterThumbHash: item.posterThumbHash,
releaseDate: item.releaseDate,
firstAirDate: item.firstAirDate,
voteAverage: item.voteAverage,
@@ -277,6 +278,7 @@ export default function TitleDetailScreen() {
{title.backdropPath && (
<Image
source={{ uri: title.backdropPath }}
thumbHash={title.backdropThumbHash}
style={{
width: "100%",
height: "100%",
@@ -366,6 +368,7 @@ export default function TitleDetailScreen() {
>
<Image
source={{ uri: title.posterPath }}
thumbHash={title.posterThumbHash}
style={{
width: "100%",
height: "100%",
@@ -546,6 +549,7 @@ export default function TitleDetailScreen() {
watchedEpisodeIds={watchedEpisodeIds}
userStatus={userInfo.data?.status ?? null}
backdropPath={title.backdropPath}
backdropThumbHash={title.backdropThumbHash}
/>
)}
@@ -15,6 +15,7 @@ export interface ContinueWatchingItem {
id: string;
title: string;
backdropPath: string | null;
backdropThumbHash?: string | null;
};
watchedEpisodes: number;
totalEpisodes: number;
@@ -23,6 +24,7 @@ export interface ContinueWatchingItem {
episodeNumber: number;
name: string | null;
stillPath: string | null;
stillThumbHash?: string | null;
} | null;
}
@@ -61,6 +63,10 @@ export function ContinueWatchingCard({ item }: { item: ContinueWatchingItem }) {
uri: (item.nextEpisode?.stillPath ??
item.title.backdropPath) as string,
}}
thumbHash={
item.nextEpisode?.stillThumbHash ??
item.title.backdropThumbHash
}
className="h-full w-full"
contentFit="cover"
/>
@@ -9,6 +9,7 @@ export interface PosterRowItem {
title: string;
type: string;
posterPath: string | null;
posterThumbHash?: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
voteAverage?: number | null;
@@ -55,6 +56,7 @@ export function HorizontalPosterRow({
title={item.title}
type={item.type as "movie" | "tv"}
posterPath={item.posterPath}
posterThumbHash={item.posterThumbHash}
releaseDate={item.releaseDate ?? item.firstAirDate}
voteAverage={item.voteAverage}
userStatus={item.userStatus}
@@ -12,6 +12,7 @@ export function CastCard({
name: string;
character: string | null;
profilePath: string | null;
profileThumbHash?: string | null;
};
}) {
return (
@@ -23,6 +24,7 @@ export function CastCard({
{person.profilePath && (
<Image
source={{ uri: person.profilePath }}
thumbHash={person.profileThumbHash}
className="h-full w-full"
contentFit="cover"
/>
@@ -14,11 +14,13 @@ export function ContinueWatchingBanner({
watchedEpisodeIds,
userStatus,
backdropPath,
backdropThumbHash,
}: {
seasons: Season[];
watchedEpisodeIds: Set<string>;
userStatus: string | null;
backdropPath: string | null;
backdropThumbHash?: string | null;
}) {
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
@@ -54,6 +56,7 @@ export function ContinueWatchingBanner({
{stillUrl && (
<Image
source={{ uri: stillUrl }}
thumbHash={nextEpisode.stillThumbHash ?? backdropThumbHash}
className="h-full w-full"
contentFit="cover"
/>
+8 -2
View File
@@ -5,8 +5,9 @@ import { resolveUrl } from "@/lib/server-url";
export function Image({
source,
className,
thumbHash,
...props
}: ImageProps & { className?: string }) {
}: ImageProps & { className?: string; thumbHash?: string | null }) {
const resolved =
source && typeof source === "object" && "uri" in source && source.uri
? { ...source, uri: resolveUrl(source.uri) ?? undefined }
@@ -15,6 +16,11 @@ export function Image({
const style = useResolveClassNames(className ?? "");
return (
<ExpoImage source={resolved} style={[style, props.style]} {...props} />
<ExpoImage
source={resolved}
placeholder={thumbHash ? { thumbhash: thumbHash } : undefined}
style={[style, props.style]}
{...props}
/>
);
}
@@ -35,6 +35,7 @@ interface PosterCardProps {
title: string;
type: "movie" | "tv";
posterPath: string | null;
posterThumbHash?: string | null;
releaseDate?: string | null;
voteAverage?: number | null;
userStatus?: TitleStatus | null;
@@ -55,6 +56,7 @@ export function PosterCard({
title,
type,
posterPath,
posterThumbHash,
releaseDate,
voteAverage,
userStatus,
@@ -117,6 +119,7 @@ export function PosterCard({
{posterPath ? (
<Image
source={{ uri: posterPath }}
thumbHash={posterThumbHash}
style={{ width: "100%", height: "100%" }}
contentFit="cover"
recyclingKey={`poster-${tmdbId}`}