diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index a9a2e2fbb..5f94fad70 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 }) + } + /> Promise) | null>(null); + // Live TV opens a server-side live stream via autoOpenLiveStream. If it is + // never closed, Jellyfin's M3U tuner limit fills up and every channel then + // fails with "simultaneous stream limit has been reached". reportPlaybackStopped + // handles a clean stop, but a channel switch (the player re-fetches in place) + // or an unmount after an error bypass it, so track the open live stream and + // release it on those paths too. + const apiRef = useRef(api); + useEffect(() => { + apiRef.current = api; + }, [api]); + + const releaseLiveStream = useCallback( + (liveStreamId: string | null) => { + if (!liveStreamId || !apiRef.current || offline) return; + // Best effort: a failed close must not break teardown, and the slot is + // also freed by the server's reap as a backstop. + getMediaInfoApi(apiRef.current) + .closeLiveStream({ liveStreamId }) + .catch(() => {}); + }, + [offline], + ); + + // The effect cleanup releases the live stream both when it changes (channel + // switch, which re-runs the effect) and when the player unmounts, so no + // manual previous-id tracking is needed. + useEffect(() => { + const liveStreamId = stream?.mediaSource?.LiveStreamId ?? null; + return () => releaseLiveStream(liveStreamId); + }, [stream?.mediaSource?.LiveStreamId, releaseLiveStream]); + useEffect(() => { const fetchStreamData = async (): Promise => { setStreamStatus({ isLoading: true, isError: false }); @@ -445,6 +477,12 @@ export default function DirectPlayerPage() { MediaSourceId: mediaSourceId, PositionTicks: currentTimeInTicks, PlaySessionId: stream.sessionId, + // Release the server-side live stream (and its tuner slot) on stop. + // Jellyfin only closes a live stream opened via autoOpenLiveStream when + // the stop report carries its LiveStreamId; without it the stream leaks + // and Live TV eventually fails for everyone with "M3U simultaneous + // stream limit has been reached". Undefined for non-live items (no-op). + LiveStreamId: stream.mediaSource?.LiveStreamId ?? undefined, }, }); }, [api, item, mediaSourceId, stream, progress, offline]); diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx index c0f4a2650..fc20e1dfc 100644 --- a/components/ContinueWatchingPoster.tsx +++ b/components/ContinueWatchingPoster.tsx @@ -35,8 +35,10 @@ const ContinueWatchingPoster: React.FC = ({ 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,8 @@ const ContinueWatchingPoster: React.FC = ({ } return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; - }, [item]); + // useEpisodePoster in deps so flipping the prop re-computes the URL live. + }, [api, item, useEpisodePoster]); if (!url) return ; diff --git a/components/home/InfiniteScrollingCollectionList.tsx b/components/home/InfiniteScrollingCollectionList.tsx index de4c64621..d9a2c5d28 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 1db314f2b..585135f6b 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() ?? []; @@ -231,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ hasTVPreferredFocus={isFirstItem} onFocus={() => handleItemFocus(item)} width={itemWidth} + preferEpisodeImage={settings?.useEpisodeImagesForNextUp} /> ); @@ -243,6 +246,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 185ad8c2f..a09ce6f0e 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 596f2d369..a62538013 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 844096177..8ce41dedb 100644 --- a/components/settings/AppearanceSettings.tsx +++ b/components/settings/AppearanceSettings.tsx @@ -27,6 +27,7 @@ export const AppearanceSettings: React.FC = () => { Linking.openURL( @@ -44,6 +45,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 /> string | undefined; + + /** For horizontal episodes, prefer the episode's own image over the series thumb */ + preferEpisodeImage?: boolean; } /** @@ -108,6 +111,7 @@ export const TVPosterCard: React.FC = ({ glowColor = "white", scaleAmount = 1.05, imageUrlGetter, + preferEpisodeImage = false, }) => { const api = useAtomValue(apiAtom); const posterSizes = useScaledTVPosterSizes(); @@ -139,9 +143,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) { @@ -173,7 +182,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 8bfd9adfb..c73f3422d 100644 --- a/translations/en.json +++ b/translations/en.json @@ -138,7 +138,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", @@ -297,6 +301,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 358550be4..7b7452659 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -274,6 +274,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; @@ -382,6 +385,7 @@ export const defaultValues: Settings = { hideBrightnessSlider: false, usePopularPlugin: true, mergeNextUpAndContinueWatching: false, + useEpisodeImagesForNextUp: false, // TV-specific settings showHomeBackdrop: true, showTVHeroCarousel: true,