mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-14 09:50:23 +01:00
feat(tv): add setting to show series poster on episode detail pages
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user