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}`}
+130 -13
View File
@@ -1,6 +1,6 @@
import { refreshAvailability } from "@sofa/core/availability";
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
import { refreshCredits } from "@sofa/core/credits";
import { refreshCredits, syncCastProfileThumbHashes } from "@sofa/core/credits";
import {
cacheEpisodeStills,
cacheImagesForTitle,
@@ -12,15 +12,22 @@ import {
refreshRecommendations,
refreshTitle,
refreshTvChildren,
syncTvChildArt,
} from "@sofa/core/metadata";
import { getSetting } from "@sofa/core/settings";
import { performTelemetryReport } from "@sofa/core/telemetry";
import {
generateTitleBackdropThumbHash,
generateTitlePosterThumbHash,
} from "@sofa/core/thumbhash";
import { performUpdateCheck } from "@sofa/core/update-check";
import { db } from "@sofa/db/client";
import { and, eq, inArray, isNotNull, lt, or } from "@sofa/db/helpers";
import { and, eq, inArray, isNotNull, lt, or, sql } from "@sofa/db/helpers";
import {
availabilityOffers,
cronRuns,
episodes,
persons,
seasons,
titleCast,
titles,
@@ -112,6 +119,83 @@ function getLibraryTitleIds(): string[] {
return rows.map((r) => r.titleId);
}
function getThumbhashBackfillTitleIds(): string[] {
const titleIds = new Set(getLibraryTitleIds());
const addIds = (ids: string[]) => {
for (const id of ids) titleIds.add(id);
};
addIds(
db
.select({ id: titles.id })
.from(titles)
.where(
or(
and(
isNotNull(titles.posterPath),
sql`${titles.posterThumbHash} IS NULL`,
),
and(
isNotNull(titles.backdropPath),
sql`${titles.backdropThumbHash} IS NULL`,
),
),
)
.all()
.map((row) => row.id),
);
addIds(
db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(
and(
isNotNull(seasons.posterPath),
sql`${seasons.posterThumbHash} IS NULL`,
),
)
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId),
);
addIds(
db
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(
and(
isNotNull(episodes.stillPath),
sql`${episodes.stillThumbHash} IS NULL`,
),
)
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId),
);
addIds(
db
.select({ titleId: titleCast.titleId })
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(
and(
isNotNull(persons.profilePath),
sql`${persons.profileThumbHash} IS NULL`,
),
)
.groupBy(titleCast.titleId)
.all()
.map((row) => row.titleId),
);
return [...titleIds];
}
// Refresh titles where lastFetchedAt is stale
async function nightlyRefreshLibrary() {
const libraryIds = getLibraryTitleIds();
@@ -248,25 +332,58 @@ async function refreshTvChildrenJob() {
if (titlesWithStaleSeasons.has(show.id)) {
const details = await getTvDetails(show.tmdbId);
await refreshTvChildren(show.id, show.tmdbId, details.number_of_seasons);
await syncTvChildArt(show.id, { warmCache: true });
await Bun.sleep(RATE_LIMIT_MS);
}
}
}
async function cacheImagesJob() {
if (!imageCacheEnabled()) return;
const titleIds = getThumbhashBackfillTitleIds();
log.debug(
`Caching images for ${titleIds.length} titles needing art backfill`,
);
const libraryIds = getLibraryTitleIds();
log.debug(`Caching images for ${libraryIds.length} library titles`);
for (const titleId of libraryIds) {
for (const titleId of titleIds) {
try {
await Promise.all([
cacheImagesForTitle(titleId),
cacheEpisodeStills(titleId),
cacheProviderLogos(titleId),
cacheProfilePhotos(titleId),
]);
const title = db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
if (!title) continue;
// Phase 1: warm the image cache so thumbhash generation can read from disk
if (imageCacheEnabled()) {
await Promise.all([
cacheImagesForTitle(titleId),
cacheEpisodeStills(titleId),
cacheProviderLogos(titleId),
cacheProfilePhotos(titleId),
]);
}
// Phase 2: generate thumbhashes (reads from warm cache, no duplicate downloads)
const hashTasks: Promise<unknown>[] = [];
if (!title.posterThumbHash && title.posterPath) {
hashTasks.push(generateTitlePosterThumbHash(titleId, title.posterPath));
}
if (!title.backdropThumbHash && title.backdropPath) {
hashTasks.push(
generateTitleBackdropThumbHash(titleId, title.backdropPath),
);
}
if (title.type === "tv") {
hashTasks.push(syncTvChildArt(titleId, { warmCache: false }));
}
hashTasks.push(
syncCastProfileThumbHashes(titleId, undefined, { warmCache: false }),
);
await Promise.all(hashTasks);
} catch (err) {
log.warn(`Failed to cache images for title ${titleId}:`, err);
}
@@ -0,0 +1,43 @@
import { db } from "@sofa/db/client";
import { and, inArray } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
type BrowseLookup = {
tmdbId: number;
type: "movie" | "tv";
};
export function browseLookupKey({ tmdbId, type }: BrowseLookup): string {
return `${tmdbId}-${type}`;
}
export function getBrowsePosterThumbHashes(lookups: BrowseLookup[]) {
if (lookups.length === 0) {
return new Map<string, string | null>();
}
const tmdbIds = [...new Set(lookups.map((lookup) => lookup.tmdbId))];
const mediaTypes = [...new Set(lookups.map((lookup) => lookup.type))];
const rows = db
.select({
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(
and(inArray(titles.tmdbId, tmdbIds), inArray(titles.type, mediaTypes)),
)
.all();
return new Map(
rows.map((row) => [
browseLookupKey({
tmdbId: row.tmdbId,
type: row.type as "movie" | "tv",
}),
row.posterThumbHash,
]),
);
}
@@ -25,6 +25,7 @@ export const continueWatching = os.dashboard.continueWatching
id: item.title.id,
title: item.title.title,
backdropPath: tmdbImageUrl(item.title.backdropPath, "backdrops"),
backdropThumbHash: item.title.backdropThumbHash,
},
nextEpisode: item.nextEpisode
? {
@@ -32,6 +33,7 @@ export const continueWatching = os.dashboard.continueWatching
episodeNumber: item.nextEpisode.episodeNumber,
name: item.nextEpisode.name,
stillPath: tmdbImageUrl(item.nextEpisode.stillPath, "stills"),
stillThumbHash: item.nextEpisode.stillThumbHash,
}
: null,
totalEpisodes: item.totalEpisodes,
@@ -50,6 +52,7 @@ export const library = os.dashboard.library
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
posterThumbHash: t.posterThumbHash ?? null,
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
@@ -71,6 +74,7 @@ export const recommendations = os.dashboard.recommendations
type: t.type,
title: t.title,
posterPath: tmdbImageUrl(t.posterPath, "posters"),
posterThumbHash: t.posterThumbHash ?? null,
releaseDate: t.releaseDate ?? null,
firstAirDate: t.firstAirDate ?? null,
voteAverage: t.voteAverage,
+10 -1
View File
@@ -8,6 +8,10 @@ import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
export const discover = os.discover
.use(authed)
@@ -31,7 +35,7 @@ export const discover = os.discover
first_air_date?: string;
};
const items = ((results.results ?? []) as DiscoverResult[])
const baseItems = ((results.results ?? []) as DiscoverResult[])
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
@@ -42,6 +46,11 @@ export const discover = os.discover
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: r.vote_average ?? null,
}));
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const [userStatuses, episodeProgress] =
+16 -2
View File
@@ -8,6 +8,10 @@ import { isTmdbConfigured } from "@sofa/tmdb/config";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
import {
browseLookupKey,
getBrowsePosterThumbHashes,
} from "./browse-thumbhashes";
function requireTmdb() {
if (!isTmdbConfigured()) {
@@ -25,7 +29,7 @@ export const trending = os.explore.trending
const data = await getTrending(input.type, "day");
const results = (data.results ?? []) as Record<string, unknown>[];
const items = results
const baseItems = results
.filter((r) => r.poster_path)
.map((r) => {
const mediaType =
@@ -45,6 +49,11 @@ export const trending = os.explore.trending
voteAverage: (r.vote_average as number | undefined) ?? null,
};
});
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
const heroResult = results.find(
(r) =>
@@ -83,7 +92,7 @@ export const popular = os.explore.popular
requireTmdb();
const data = await getPopular(input.type);
const items = ((data.results ?? []) as Record<string, unknown>[])
const baseItems = ((data.results ?? []) as Record<string, unknown>[])
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id as number,
@@ -94,6 +103,11 @@ export const popular = os.explore.popular
firstAirDate: (r.first_air_date as string | undefined) ?? null,
voteAverage: (r.vote_average as number | undefined) ?? null,
}));
const posterThumbHashes = getBrowsePosterThumbHashes(baseItems);
const items = baseItems.map((item) => ({
...item,
posterThumbHash: posterThumbHashes.get(browseLookupKey(item)) ?? null,
}));
const lookups = items.map((r) => ({ tmdbId: r.tmdbId, type: r.type }));
const [userStatuses, episodeProgress] =
-3
View File
@@ -1,6 +1,4 @@
import { getInstanceId } from "@sofa/core/settings";
import { db } from "@sofa/db/client";
import { sql } from "@sofa/db/helpers";
import { createLogger } from "@sofa/logger";
import { Hono } from "hono";
@@ -10,7 +8,6 @@ const app = new Hono();
app.get("/", (c) => {
try {
db.run(sql`SELECT 1`);
return c.json({ status: "healthy", instanceId: getInstanceId() }, 200);
} catch (err) {
log.error("Health check failed:", err);
+1
View File
@@ -40,6 +40,7 @@
"shadcn": "4.0.6",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
"thumbhash": "catalog:",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"youtube-video-element": "1.9.0",
@@ -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);
}