mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
feat: generate and display thumbhash blur placeholders for TMDB images
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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