From 8a781f2462c2631238dd49a076a7be813bfdf0bb Mon Sep 17 00:00:00 2001 From: Gauvain Date: Sun, 28 Jun 2026 01:22:45 +0200 Subject: [PATCH] feat(appearance): episode images for Next Up & Continue Watching - Fix black episode thumbnails in the Next Up / Continue Watching rows: build the parent Thumb URL from the matched ParentThumbItemId + ParentThumbImageTag pair, instead of pairing ParentBackdropItemId with the thumb tag (different parent -> 404 -> black). Fixed on mobile (ContinueWatchingPoster) and TV (TVPosterCard, TVHeroCarousel). - Add a "Use episode images for Next Up & Continue Watching" setting (default off = series image, matching Jellyfin), wired into the home rows on mobile and TV. - Add helper descriptions under the Appearance settings rows. --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 7 +++++++ components/ContinueWatchingPoster.tsx | 10 ++++++--- .../home/InfiniteScrollingCollectionList.tsx | 7 ++++++- .../InfiniteScrollingCollectionList.tv.tsx | 4 ++++ components/home/ScrollingCollectionList.tsx | 7 ++++++- components/home/TVHeroCarousel.tsx | 7 ++++--- components/settings/AppearanceSettings.tsx | 21 +++++++++++++++++++ components/tv/TVPosterCard.tsx | 17 +++++++++++---- translations/en.json | 5 +++++ utils/atoms/settings.ts | 4 ++++ 10 files changed, 77 insertions(+), 12 deletions(-) diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 905d02bb..33655498 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -854,6 +854,13 @@ export default function SettingsTV() { updateSettings({ mergeNextUpAndContinueWatching: value }) } /> + + updateSettings({ useEpisodeImagesForNextUp: value }) + } + /> = ({ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; } if (item.Type === "Episode") { - if (item.ParentBackdropItemId && item.ParentThumbImageTag) { - return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; + // Matched pair: the parent that owns the Thumb (ParentThumbItemId), not the + // backdrop owner — otherwise the Thumb tag is requested on the wrong item → black. + if (item.ParentThumbItemId && item.ParentThumbImageTag) { + return `${api?.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`; } return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; @@ -61,7 +63,9 @@ const ContinueWatchingPoster: React.FC = ({ } return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; - }, [item]); + // useEpisodePoster in deps so flipping the setting re-computes the URL live + // (no app restart needed). + }, [item, useEpisodePoster]); if (!url) return ; diff --git a/components/home/InfiniteScrollingCollectionList.tsx b/components/home/InfiniteScrollingCollectionList.tsx index de4c6462..d9a2c5d2 100644 --- a/components/home/InfiniteScrollingCollectionList.tsx +++ b/components/home/InfiniteScrollingCollectionList.tsx @@ -15,6 +15,7 @@ import { import { SectionHeader } from "@/components/common/SectionHeader"; import { Text } from "@/components/common/Text"; import MoviePoster from "@/components/posters/MoviePoster"; +import { useSettings } from "@/utils/atoms/settings"; import { Colors } from "../../constants/Colors"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; @@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ }, [isSuccess, onLoaded]); const { t } = useTranslation(); + const { settings } = useSettings(); // Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates) const allItems = useMemo(() => { @@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC = ({ `} > {item.Type === "Episode" && orientation === "horizontal" && ( - + )} {item.Type === "Episode" && orientation === "vertical" && ( diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index ad4553c0..19cb5abd 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography"; import useRouter from "@/hooks/useAppRouter"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; +import { useSettings } from "@/utils/atoms/settings"; import { scaleSize } from "@/utils/scaleSize"; // Extra padding to accommodate scale animation (1.05x) and glow shadow @@ -165,6 +166,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ }); const { t } = useTranslation(); + const { settings } = useSettings(); const allItems = useMemo(() => { const items = data?.pages.flat() ?? []; @@ -225,6 +227,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} width={itemWidth} + preferEpisodeImage={settings?.useEpisodeImagesForNextUp} /> ); @@ -237,6 +240,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ showItemActions, handleItemFocus, ITEM_GAP, + settings?.useEpisodeImagesForNextUp, ], ); diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx index 185ad8c2..a09ce6f0 100644 --- a/components/home/ScrollingCollectionList.tsx +++ b/components/home/ScrollingCollectionList.tsx @@ -9,6 +9,7 @@ import { ScrollView, View, type ViewProps } from "react-native"; import { Text } from "@/components/common/Text"; import MoviePoster from "@/components/posters/MoviePoster"; import { useInView } from "@/hooks/useInView"; +import { useSettings } from "@/utils/atoms/settings"; import ContinueWatchingPoster from "../ContinueWatchingPoster"; import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { ItemCardText } from "../ItemCardText"; @@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC = ({ }); const { t } = useTranslation(); + const { settings } = useSettings(); // Show skeleton if loading OR if lazy loading is enabled and not in view yet const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView); @@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC = ({ `} > {item.Type === "Episode" && orientation === "horizontal" && ( - + )} {item.Type === "Episode" && orientation === "vertical" && ( diff --git a/components/home/TVHeroCarousel.tsx b/components/home/TVHeroCarousel.tsx index d99b1d97..68c066c7 100644 --- a/components/home/TVHeroCarousel.tsx +++ b/components/home/TVHeroCarousel.tsx @@ -65,10 +65,11 @@ const HeroCard: React.FC = React.memo( const posterUrl = useMemo(() => { if (!api) return null; - // For episodes, always use series thumb + // For episodes, always use series thumb. + // Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId. if (item.Type === "Episode") { - if (item.ParentThumbImageTag) { - return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; + if (item.ParentThumbItemId && item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`; } if (item.SeriesId) { return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`; diff --git a/components/settings/AppearanceSettings.tsx b/components/settings/AppearanceSettings.tsx index 8434c6fc..2e982a08 100644 --- a/components/settings/AppearanceSettings.tsx +++ b/components/settings/AppearanceSettings.tsx @@ -28,6 +28,7 @@ export const AppearanceSettings: React.FC = () => { Linking.openURL( @@ -45,6 +46,9 @@ export const AppearanceSettings: React.FC = () => { { } /> + + + updateSettings({ useEpisodeImagesForNextUp: value }) + } + /> + { router.push("/settings/appearance/hide-libraries/page") } title={t("home.settings.other.hide_libraries")} + subtitle={t("home.settings.other.select_libraries_you_want_to_hide")} showArrow /> diff --git a/components/tv/TVPosterCard.tsx b/components/tv/TVPosterCard.tsx index 669c0a2d..57d16987 100644 --- a/components/tv/TVPosterCard.tsx +++ b/components/tv/TVPosterCard.tsx @@ -70,6 +70,9 @@ export interface TVPosterCardProps { /** Custom image URL getter - if not provided, uses smart URL logic */ imageUrlGetter?: (item: BaseItemDto) => string | undefined; + + /** For horizontal episodes, prefer the episode's own image over the series thumb */ + preferEpisodeImage?: boolean; } /** @@ -106,6 +109,7 @@ export const TVPosterCard: React.FC = ({ glowColor = "white", scaleAmount = 1.05, imageUrlGetter, + preferEpisodeImage = false, }) => { const { t } = useTranslation(); const api = useAtomValue(apiAtom); @@ -138,9 +142,14 @@ export const TVPosterCard: React.FC = ({ if (orientation === "horizontal") { // Episode: prefer series thumb image for consistent look (like hero section) if (item.Type === "Episode") { - // First try parent/series thumb (horizontal series artwork) - if (item.ParentBackdropItemId && item.ParentThumbImageTag) { - return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; + // Opt-in: use the episode's own image instead of the series thumb. + if (preferEpisodeImage && item.ImageTags?.Primary) { + return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=600&quality=80&tag=${item.ImageTags.Primary}`; + } + // First try parent/series thumb (horizontal series artwork). + // Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId. + if (item.ParentThumbItemId && item.ParentThumbImageTag) { + return `${api.basePath}/Items/${item.ParentThumbItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`; } // Fall back to episode's own primary image if (item.ImageTags?.Primary) { @@ -172,7 +181,7 @@ export const TVPosterCard: React.FC = ({ item, width: width * 2, // 2x for quality on large screens }); - }, [api, item, orientation, width, imageUrlGetter]); + }, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]); // Progress calculation const progress = useMemo(() => { diff --git a/translations/en.json b/translations/en.json index 49ad22a7..a96c970c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -141,7 +141,11 @@ "appearance": { "title": "Appearance", "merge_next_up_continue_watching": "Merge Continue watching & Next up", + "merge_next_up_continue_watching_hint": "Combine Continue Watching and Next Up into a single home row.", + "use_episode_images_next_up": "Use episode images for Next Up & Continue Watching", + "use_episode_images_next_up_hint": "Show each episode's own thumbnail in the Next Up and Continue Watching rows instead of the series image.", "hide_remote_session_button": "Hide remote session button", + "hide_remote_session_button_hint": "Hide the remote-sessions button from the home header.", "show_home_backdrop": "Dynamic home backdrop", "show_hero_carousel": "Hero carousel", "show_series_poster_on_episode": "Show series poster on episodes", @@ -305,6 +309,7 @@ }, "safe_area_in_controls": "Safe area in controls", "show_custom_menu_links": "Show custom menu links", + "show_custom_menu_links_hint": "Show the custom links your server administrator added in the web config.", "show_large_home_carousel": "Show large home carousel (beta)", "hide_libraries": "Hide libraries", "select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index a5752ba2..0d48899e 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -273,6 +273,9 @@ export type Settings = { hideBrightnessSlider: boolean; usePopularPlugin: boolean; mergeNextUpAndContinueWatching: boolean; + // Use the episode's own image (instead of the series thumb) for the + // "Next Up" and "Continue Watching" home rows. + useEpisodeImagesForNextUp: boolean; // TV-specific settings showHomeBackdrop: boolean; showTVHeroCarousel: boolean; @@ -376,6 +379,7 @@ export const defaultValues: Settings = { hideBrightnessSlider: false, usePopularPlugin: true, mergeNextUpAndContinueWatching: false, + useEpisodeImagesForNextUp: false, // TV-specific settings showHomeBackdrop: true, showTVHeroCarousel: true,