feat(tv): add setting to show series poster on episode detail pages

This commit is contained in:
Fredrik Burmester
2026-01-25 23:01:08 +01:00
parent 875a017e8c
commit dca7cc99f2
5 changed files with 41 additions and 16 deletions

View File

@@ -15,6 +15,7 @@ import {
TVSettingsTextInput, TVSettingsTextInput,
TVSettingsToggle, TVSettingsToggle,
} from "@/components/tv"; } from "@/components/tv";
import { useScaledTVTypography } from "@/constants/TVTypography";
import { useTVOptionModal } from "@/hooks/useTVOptionModal"; import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { import {
@@ -31,6 +32,7 @@ export default function SettingsTV() {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const { showOptions } = useTVOptionModal(); const { showOptions } = useTVOptionModal();
const typography = useScaledTVTypography();
// Local state for OpenSubtitles API key (only commit on blur) // Local state for OpenSubtitles API key (only commit on blur)
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
@@ -204,7 +206,7 @@ export default function SettingsTV() {
{/* Header */} {/* Header */}
<Text <Text
style={{ style={{
fontSize: 42, fontSize: typography.title,
fontWeight: "bold", fontWeight: "bold",
color: "#FFFFFF", color: "#FFFFFF",
marginBottom: 8, marginBottom: 8,
@@ -347,7 +349,7 @@ export default function SettingsTV() {
<Text <Text
style={{ style={{
color: "#9CA3AF", color: "#9CA3AF",
fontSize: 14, fontSize: typography.callout - 2,
marginBottom: 16, marginBottom: 16,
marginLeft: 8, marginLeft: 8,
}} }}
@@ -371,7 +373,7 @@ export default function SettingsTV() {
<Text <Text
style={{ style={{
color: "#6B7280", color: "#6B7280",
fontSize: 12, fontSize: typography.callout - 4,
marginTop: 8, marginTop: 8,
marginLeft: 8, marginLeft: 8,
}} }}
@@ -413,6 +415,13 @@ export default function SettingsTV() {
value={settings.showTVHeroCarousel} value={settings.showTVHeroCarousel}
onToggle={(value) => updateSettings({ showTVHeroCarousel: value })} onToggle={(value) => updateSettings({ showTVHeroCarousel: value })}
/> />
<TVSettingsToggle
label={t("home.settings.appearance.show_series_poster_on_episode")}
value={settings.showSeriesPosterOnEpisode}
onToggle={(value) =>
updateSettings({ showSeriesPosterOnEpisode: value })
}
/>
{/* User Section */} {/* User Section */}
<TVSectionHeader <TVSectionHeader

View File

@@ -387,17 +387,18 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
return getPrimaryImageUrlById({ api, id: seasonId, width: 300 }); return getPrimaryImageUrlById({ api, id: seasonId, width: 300 });
}, [api, item?.Type, item?.SeasonId, item?.ParentId]); }, [api, item?.Type, item?.SeasonId, item?.ParentId]);
// Episode thumbnail URL - 16:9 horizontal image for episode items // Episode thumbnail URL - episode's own primary image (16:9 for episodes)
const episodeThumbnailUrl = useMemo(() => { const episodeThumbnailUrl = useMemo(() => {
if (item?.Type !== "Episode" || !api) return null; if (item?.Type !== "Episode" || !api) return null;
// Use parent backdrop thumb if available (series/season thumbnail)
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
}
// Fall back to episode's primary image (which is usually 16:9 for episodes)
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`; return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=700&quality=80`;
}, [api, item]); }, [api, item]);
// Series thumb URL - used when showSeriesPosterOnEpisode setting is enabled
const seriesThumbUrl = useMemo(() => {
if (item?.Type !== "Episode" || !item.SeriesId || !api) return null;
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=700&quality=80`;
}, [api, item]);
// Determine which option button is the last one (for focus guide targeting) // Determine which option button is the last one (for focus guide targeting)
const lastOptionButton = useMemo(() => { const lastOptionButton = useMemo(() => {
const hasSubtitleOption = const hasSubtitleOption =
@@ -738,9 +739,14 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
shadowRadius: 20, shadowRadius: 20,
}} }}
> >
{item.Type === "Episode" && episodeThumbnailUrl ? ( {item.Type === "Episode" ? (
<Image <Image
source={{ uri: episodeThumbnailUrl }} source={{
uri:
settings.showSeriesPosterOnEpisode && seriesThumbUrl
? seriesThumbUrl
: episodeThumbnailUrl!,
}}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
contentFit='cover' contentFit='cover'
/> />

View File

@@ -63,17 +63,24 @@ const HeroCard: React.FC<HeroCardProps> = React.memo(
const posterUrl = useMemo(() => { const posterUrl = useMemo(() => {
if (!api) return null; if (!api) return null;
// Try thumb first, then primary
// For episodes, always use series thumb
if (item.Type === "Episode") {
if (item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
}
if (item.SeriesId) {
return `${api.basePath}/Items/${item.SeriesId}/Images/Thumb?fillHeight=400&quality=80`;
}
}
// For non-episodes, use item's own thumb/primary
if (item.ImageTags?.Thumb) { if (item.ImageTags?.Thumb) {
return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`; return `${api.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ImageTags.Thumb}`;
} }
if (item.ImageTags?.Primary) { if (item.ImageTags?.Primary) {
return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`; return `${api.basePath}/Items/${item.Id}/Images/Primary?fillHeight=400&quality=80&tag=${item.ImageTags.Primary}`;
} }
// For episodes, use series thumb
if (item.Type === "Episode" && item.ParentThumbImageTag) {
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=400&quality=80&tag=${item.ParentThumbImageTag}`;
}
return null; return null;
}, [api, item]); }, [api, item]);

View File

@@ -127,6 +127,7 @@
"hide_remote_session_button": "Hide Remote Session Button", "hide_remote_session_button": "Hide Remote Session Button",
"show_home_backdrop": "Dynamic Home Backdrop", "show_home_backdrop": "Dynamic Home Backdrop",
"show_hero_carousel": "Hero Carousel", "show_hero_carousel": "Hero Carousel",
"show_series_poster_on_episode": "Show Series Poster on Episodes",
"text_size": "Text Size", "text_size": "Text Size",
"text_size_small": "Small", "text_size_small": "Small",
"text_size_default": "Default", "text_size_default": "Default",

View File

@@ -211,6 +211,7 @@ export type Settings = {
showHomeBackdrop: boolean; showHomeBackdrop: boolean;
showTVHeroCarousel: boolean; showTVHeroCarousel: boolean;
tvTypographyScale: TVTypographyScale; tvTypographyScale: TVTypographyScale;
showSeriesPosterOnEpisode: boolean;
// Appearance // Appearance
hideRemoteSessionButton: boolean; hideRemoteSessionButton: boolean;
hideWatchlistsTab: boolean; hideWatchlistsTab: boolean;
@@ -301,6 +302,7 @@ export const defaultValues: Settings = {
showHomeBackdrop: true, showHomeBackdrop: true,
showTVHeroCarousel: true, showTVHeroCarousel: true,
tvTypographyScale: TVTypographyScale.Default, tvTypographyScale: TVTypographyScale.Default,
showSeriesPosterOnEpisode: false,
// Appearance // Appearance
hideRemoteSessionButton: false, hideRemoteSessionButton: false,
hideWatchlistsTab: false, hideWatchlistsTab: false,