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.
This commit is contained in:
Gauvain
2026-06-28 01:22:45 +02:00
parent 4a0dc44251
commit 8a781f2462
10 changed files with 77 additions and 12 deletions

View File

@@ -854,6 +854,13 @@ export default function SettingsTV() {
updateSettings({ mergeNextUpAndContinueWatching: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.use_episode_images_next_up")}
value={settings.useEpisodeImagesForNextUp}
onToggle={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_home_backdrop")}
value={settings.showHomeBackdrop}

View File

@@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
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<ContinueWatchingPosterProps> = ({
}
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 <View className='aspect-video border border-neutral-800 w-44' />;

View File

@@ -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<Props> = ({
}, [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<Props> = ({
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
<ContinueWatchingPoster
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />

View File

@@ -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<Props> = ({
});
const { t } = useTranslation();
const { settings } = useSettings();
const allItems = useMemo(() => {
const items = data?.pages.flat() ?? [];
@@ -225,6 +227,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)}
width={itemWidth}
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
/>
</View>
);
@@ -237,6 +240,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showItemActions,
handleItemFocus,
ITEM_GAP,
settings?.useEpisodeImagesForNextUp,
],
);

View File

@@ -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<Props> = ({
});
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<Props> = ({
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
<ContinueWatchingPoster
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />

View File

@@ -65,10 +65,11 @@ const HeroCard: React.FC<HeroCardProps> = 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`;

View File

@@ -28,6 +28,7 @@ export const AppearanceSettings: React.FC = () => {
<ListGroup title={t("home.settings.appearance.title")} className=''>
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() =>
Linking.openURL(
@@ -45,6 +46,9 @@ export const AppearanceSettings: React.FC = () => {
</ListItem>
<ListItem
title={t("home.settings.appearance.merge_next_up_continue_watching")}
subtitle={t(
"home.settings.appearance.merge_next_up_continue_watching_hint",
)}
>
<SettingSwitch
value={settings.mergeNextUpAndContinueWatching}
@@ -53,8 +57,24 @@ export const AppearanceSettings: React.FC = () => {
}
/>
</ListItem>
<ListItem
title={t("home.settings.appearance.use_episode_images_next_up")}
subtitle={t(
"home.settings.appearance.use_episode_images_next_up_hint",
)}
>
<SettingSwitch
value={settings.useEpisodeImagesForNextUp}
onValueChange={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
</ListItem>
<ListItem
title={t("home.settings.appearance.hide_remote_session_button")}
subtitle={t(
"home.settings.appearance.hide_remote_session_button_hint",
)}
>
<SettingSwitch
value={settings.hideRemoteSessionButton}
@@ -68,6 +88,7 @@ export const AppearanceSettings: React.FC = () => {
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
/>
</ListGroup>

View File

@@ -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<TVPosterCardProps> = ({
glowColor = "white",
scaleAmount = 1.05,
imageUrlGetter,
preferEpisodeImage = false,
}) => {
const { t } = useTranslation();
const api = useAtomValue(apiAtom);
@@ -138,9 +142,14 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
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<TVPosterCardProps> = ({
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(() => {

View File

@@ -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.",

View File

@@ -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,