Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/api/src/modules/tmdb/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TmdbModel } from "./model";
import {
discoverFeed,
getMediaDetail,
getMediaRecommendations,
getTvEpisodeDetail,
getTvSeasonDetail,
getWatchProviders,
Expand Down Expand Up @@ -66,6 +67,18 @@ export const tmdbController = new Elysia({
},
},
)
.get(
"/:mediaType/:tmdbId/recommendations",
({ params, query }) =>
getMediaRecommendations(params.mediaType, params.tmdbId, query.language ?? DEFAULT_LANGUAGE),
{
params: "tmdb.MediaParams",
query: "tmdb.LanguageQuery",
response: {
200: "tmdb.SummaryList",
},
},
)
.get(
"/tv/:seriesId/season/:seasonNumber",
({ params, query }) =>
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/modules/whats-new/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ interface WhatsNewRelease {
}

export const WHATS_NEW_RELEASES: WhatsNewRelease[] = [
{
id: "you-may-also-like",
features: [
{
icon: "rectangle.stack",
title: { en: "You May Also Like", fr: "À voir aussi" },
description: {
en: "Discover related titles right on any movie or series page.",
fr: "Trouvez des titres similaires directement sur chaque film ou série.",
},
},
],
},
{
id: "letterboxd-account",
features: [
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/components/screens/media-detail/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";

import { ScreenHeader, ScreenToolbar, type ScreenAction } from "@/components/navigation";
import { Text } from "@/components/ui/text";
import { useMediaRouteBase } from "@/hooks/use-media-route-base";
import { useTheme } from "@/hooks/use-theme";
import { useWatchAction } from "@/hooks/watch-sessions/use-watch-action";

Expand All @@ -17,13 +18,15 @@ import { MediaParallaxHeader } from "./media-parallax-header";
import { MediaSummary } from "./media-summary";
import { OverviewSection } from "./overview-section";
import { RatingsSection } from "./ratings-section";
import { RecommendationsSection } from "./recommendations-section";
import { useMediaDetailViewModel } from "./use-media-detail-view-model";
import { WatchProvidersSection } from "./watch-providers-section";

export function MediaDetail() {
const theme = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const base = useMediaRouteBase();
const vm = useMediaDetailViewModel();

const handleRate = useCallback(() => vm.openReview(vm.myStars || undefined), [vm]);
Expand Down Expand Up @@ -148,6 +151,8 @@ export function MediaDetail() {
onOpenReviews={vm.openReviews}
/>

<RecommendationsSection tmdbId={vm.tmdbId} mediaType={vm.mediaType} base={base} />

{vm.isLoading && !vm.detail ? (
<Text size="sm" weight="regular" color={theme.textSecondary} fillWidth>
Loading…
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { StyleSheet, View } from "react-native";

import { PosterCard } from "@/components/discover/poster-card";
import { Shelf } from "@/components/discover/shelf";
import { SPACING } from "@/constants/design-tokens";
import { useMediaRecommendations } from "@/hooks/tmdb/use-media-recommendations";
import type { MediaType } from "@/lib/tmdb";
import type { MediaRouteBase } from "@/lib/navigation";

import { DetailSection } from "./detail-section";

export function RecommendationsSection({
tmdbId,
mediaType,
base,
}: {
tmdbId: number;
mediaType: MediaType;
base: MediaRouteBase;
}) {
const { t } = useTranslation();
const { media } = useMediaRecommendations(tmdbId, mediaType);

if (media.length === 0) return null;

return (
<DetailSection title={t("mediaDetail.youMayAlsoLike")}>
<View style={styles.shelf}>
<Shelf
hideHeader
data={media}
keyExtractor={(item) => `${item.media_type}-${item.id}`}
renderItem={(item, _index, cardWidth) => (
<PosterCard movie={item} width={cardWidth} base={base} />
)}
visibleCards={3}
/>
</View>
</DetailSection>
);
}

const styles = StyleSheet.create({
// The parallax body pads horizontally by SPACING.MD; break out so the shelf
// spans edge-to-edge like the cast row, letting Shelf own its own padding.
shelf: {
marginHorizontal: -SPACING.MD,
},
});
33 changes: 33 additions & 0 deletions apps/mobile/src/hooks/tmdb/use-media-recommendations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import { tmdbKeys } from "@seen/shared";
import { useTranslation } from "react-i18next";

import {
getMediaRecommendations,
type MediaType,
type TmdbMovieSummary,
tmdbLanguage,
} from "@/lib/tmdb";

interface MediaRecommendationsState {
media: TmdbMovieSummary[];
isLoading: boolean;
}

export function useMediaRecommendations(
tmdbId: number,
mediaType: MediaType,
): MediaRecommendationsState {
const { i18n } = useTranslation();
const language = tmdbLanguage(i18n.language);
const query = useQuery({
queryKey: tmdbKeys.recommendations(mediaType, tmdbId, language),
queryFn: () => getMediaRecommendations(tmdbId, mediaType, language),
enabled: Number.isFinite(tmdbId) && tmdbId > 0,
});

return {
media: query.data ?? [],
isLoading: query.isLoading,
};
}
1 change: 1 addition & 0 deletions apps/mobile/src/lib/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ export const en = {
noReviewsHint: "Be the first to share what you thought.",
retry: "Retry",
whereToWatch: "Where to watch",
youMayAlsoLike: "You may also like",
},
likes: {
like: "Like",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/i18n/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export const fr: typeof en = {
noReviewsHint: "Sois le premier à partager ton avis.",
retry: "Réessayer",
whereToWatch: "Où regarder",
youMayAlsoLike: "À voir aussi",
},
likes: {
like: "J'aime",
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/tmdb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export { getDiscoverFeed } from "./discover";
export { trendingMedia } from "./trending";
export { findByExternalId } from "./find";
export { getMovieDetail } from "./movie";
export { getMediaRecommendations } from "./recommendations";
export { getTvEpisodeDetail, getTvSeasonDetail } from "./tv";
export { getWatchProviders } from "./watch-providers";
export { tmdbLanguage } from "./client";
16 changes: 16 additions & 0 deletions apps/mobile/src/lib/tmdb/recommendations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { eden, unwrapEden } from "@/lib/eden";

import { tmdbLanguage } from "./client";
import type { MediaType, TmdbMovieSummary } from "./types";

export async function getMediaRecommendations(
tmdbId: number,
mediaType: MediaType,
language = tmdbLanguage(),
): Promise<TmdbMovieSummary[]> {
return unwrapEden<TmdbMovieSummary[]>(
eden.tmdb({ mediaType })({ tmdbId }).recommendations.get({
query: { language },
}),
);
}
3 changes: 3 additions & 0 deletions packages/shared/src/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export const discoverKeys = {
export const tmdbKeys = {
detail: (mediaType: MediaType, tmdbId: number, locale: string) =>
["tmdb", "detail", mediaType, tmdbId, locale] as const,
recommendations: (mediaType: MediaType, tmdbId: number, locale: string) =>
["tmdb", "recommendations", mediaType, tmdbId, locale] as const,
person: (personId: number, locale: string) => ["tmdb", "person", personId, locale] as const,
};

export const watchProviderKeys = {
Expand Down
Loading