Compare commits

...

2 Commits

Author SHA1 Message Date
renovate[bot]
07272f04e2 chore(deps): Update CI dependencies 2026-07-18 12:52:21 +00:00
Gauvain
e79ae53e4e feat(appearance): episode images toggle for Next Up & Continue Watching (#1817)
Some checks are pending
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Waiting to run
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Waiting to run
🏗️ Build Apps / 🍎 Build iOS IPA (Phone - Unsigned) (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (push) Waiting to run
🏗️ Build Apps / 🍎 Build tvOS IPA (Unsigned) (push) Waiting to run
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Waiting to run
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Waiting to run
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Waiting to run
🌐 Translation Sync / sync-translations (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Waiting to run
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (i18n:check) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Waiting to run
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Waiting to run
🛡️ Trivy Security Scan / 🔎 Filesystem scan (push) Waiting to run
2026-07-17 15:13:27 +02:00
12 changed files with 69 additions and 10 deletions

View File

@@ -30,13 +30,13 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3

View File

@@ -28,7 +28,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: 🌐 Sync Translations with Crowdin - name: 🌐 Sync Translations with Crowdin
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3 uses: crowdin/github-action@e0c8f73cdc0fafde9396e056c5038217000a32d1 # v2.16.4
with: with:
upload_sources: true upload_sources: true
upload_translations: true upload_translations: true

View File

@@ -59,7 +59,7 @@ jobs:
- name: 💬 Post title error - name: 💬 Post title error
if: steps.result.outputs.has_error == 'true' if: steps.result.outputs.has_error == 'true'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with: with:
number: ${{ steps.result.outputs.number }} number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error header: pr-title-lint-error
@@ -74,7 +74,7 @@ jobs:
- name: 🧹 Clear title error - name: 🧹 Clear title error
if: steps.result.outputs.has_error == 'false' if: steps.result.outputs.has_error == 'false'
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with: with:
number: ${{ steps.result.outputs.number }} number: ${{ steps.result.outputs.number }}
header: pr-title-lint-error header: pr-title-lint-error

View File

@@ -44,7 +44,7 @@ jobs:
output: trivy-results.sarif output: trivy-results.sarif
- name: 📤 Upload results to code scanning - name: 📤 Upload results to code scanning
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
with: with:
sarif_file: trivy-results.sarif sarif_file: trivy-results.sarif
category: trivy-fs category: trivy-fs

View File

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

View File

@@ -15,6 +15,7 @@ import {
import { SectionHeader } from "@/components/common/SectionHeader"; import { SectionHeader } from "@/components/common/SectionHeader";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { useSettings } from "@/utils/atoms/settings";
import { Colors } from "../../constants/Colors"; import { Colors } from "../../constants/Colors";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
@@ -85,6 +86,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
}, [isSuccess, onLoaded]); }, [isSuccess, onLoaded]);
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useSettings();
// Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates) // Flatten all pages into a single array (and de-dupe by Id to avoid UI duplicates)
const allItems = useMemo(() => { const allItems = useMemo(() => {
@@ -186,7 +188,10 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
`} `}
> >
{item.Type === "Episode" && orientation === "horizontal" && ( {item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} /> <ContinueWatchingPoster
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)} )}
{item.Type === "Episode" && orientation === "vertical" && ( {item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} /> <SeriesPoster item={item} />

View File

@@ -24,6 +24,7 @@ import { useScaledTVTypography } from "@/constants/TVTypography";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal"; import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings";
import { scaleSize } from "@/utils/scaleSize"; import { scaleSize } from "@/utils/scaleSize";
// Extra padding to accommodate scale animation (1.05x) and glow shadow // 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 { t } = useTranslation();
const { settings } = useSettings();
const allItems = useMemo(() => { const allItems = useMemo(() => {
const items = data?.pages.flat() ?? []; const items = data?.pages.flat() ?? [];
@@ -231,6 +233,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
hasTVPreferredFocus={isFirstItem} hasTVPreferredFocus={isFirstItem}
onFocus={() => handleItemFocus(item)} onFocus={() => handleItemFocus(item)}
width={itemWidth} width={itemWidth}
preferEpisodeImage={settings?.useEpisodeImagesForNextUp}
/> />
</View> </View>
); );
@@ -243,6 +246,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showItemActions, showItemActions,
handleItemFocus, handleItemFocus,
ITEM_GAP, 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 { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster"; import MoviePoster from "@/components/posters/MoviePoster";
import { useInView } from "@/hooks/useInView"; import { useInView } from "@/hooks/useInView";
import { useSettings } from "@/utils/atoms/settings";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter"; import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
@@ -50,6 +51,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
}); });
const { t } = useTranslation(); const { t } = useTranslation();
const { settings } = useSettings();
// Show skeleton if loading OR if lazy loading is enabled and not in view yet // Show skeleton if loading OR if lazy loading is enabled and not in view yet
const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView); const shouldShowSkeleton = isLoading || (enableLazyLoading && !isInView);
@@ -108,7 +110,10 @@ export const ScrollingCollectionList: React.FC<Props> = ({
`} `}
> >
{item.Type === "Episode" && orientation === "horizontal" && ( {item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} /> <ContinueWatchingPoster
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)} )}
{item.Type === "Episode" && orientation === "vertical" && ( {item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} /> <SeriesPoster item={item} />

View File

@@ -27,6 +27,7 @@ export const AppearanceSettings: React.FC = () => {
<ListGroup title={t("home.settings.appearance.title")} className=''> <ListGroup title={t("home.settings.appearance.title")} className=''>
<ListItem <ListItem
title={t("home.settings.other.show_custom_menu_links")} title={t("home.settings.other.show_custom_menu_links")}
subtitle={t("home.settings.other.show_custom_menu_links_hint")}
disabled={pluginSettings?.showCustomMenuLinks?.locked} disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() => onPress={() =>
Linking.openURL( Linking.openURL(
@@ -44,6 +45,9 @@ export const AppearanceSettings: React.FC = () => {
</ListItem> </ListItem>
<ListItem <ListItem
title={t("home.settings.appearance.merge_next_up_continue_watching")} title={t("home.settings.appearance.merge_next_up_continue_watching")}
subtitle={t(
"home.settings.appearance.merge_next_up_continue_watching_hint",
)}
> >
<Switch <Switch
value={settings.mergeNextUpAndContinueWatching} value={settings.mergeNextUpAndContinueWatching}
@@ -52,15 +56,32 @@ export const AppearanceSettings: React.FC = () => {
} }
/> />
</ListItem> </ListItem>
<ListItem
title={t("home.settings.appearance.use_episode_images_next_up")}
subtitle={t(
"home.settings.appearance.use_episode_images_next_up_hint",
)}
>
<Switch
value={settings.useEpisodeImagesForNextUp}
onValueChange={(value) =>
updateSettings({ useEpisodeImagesForNextUp: value })
}
/>
</ListItem>
<ListItem <ListItem
onPress={() => onPress={() =>
router.push("/settings/appearance/hide-libraries/page") router.push("/settings/appearance/hide-libraries/page")
} }
title={t("home.settings.other.hide_libraries")} title={t("home.settings.other.hide_libraries")}
subtitle={t("home.settings.other.select_libraries_you_want_to_hide")}
showArrow showArrow
/> />
<ListItem <ListItem
title={t("home.settings.appearance.hide_remote_session_button")} title={t("home.settings.appearance.hide_remote_session_button")}
subtitle={t(
"home.settings.appearance.hide_remote_session_button_hint",
)}
> >
<Switch <Switch
value={settings.hideRemoteSessionButton} value={settings.hideRemoteSessionButton}

View File

@@ -72,6 +72,9 @@ export interface TVPosterCardProps {
/** Custom image URL getter - if not provided, uses smart URL logic */ /** Custom image URL getter - if not provided, uses smart URL logic */
imageUrlGetter?: (item: BaseItemDto) => string | undefined; imageUrlGetter?: (item: BaseItemDto) => 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<TVPosterCardProps> = ({
glowColor = "white", glowColor = "white",
scaleAmount = 1.05, scaleAmount = 1.05,
imageUrlGetter, imageUrlGetter,
preferEpisodeImage = false,
}) => { }) => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const posterSizes = useScaledTVPosterSizes(); const posterSizes = useScaledTVPosterSizes();
@@ -139,6 +143,10 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
if (orientation === "horizontal") { if (orientation === "horizontal") {
// Episode: prefer series thumb image for consistent look (like hero section) // Episode: prefer series thumb image for consistent look (like hero section)
if (item.Type === "Episode") { if (item.Type === "Episode") {
// 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). // First try parent/series thumb (horizontal series artwork).
// Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId. // Matched pair: ParentThumbItemId owns the Thumb tag, not ParentBackdropItemId.
if (item.ParentThumbItemId && item.ParentThumbImageTag) { if (item.ParentThumbItemId && item.ParentThumbImageTag) {
@@ -174,7 +182,7 @@ export const TVPosterCard: React.FC<TVPosterCardProps> = ({
item, item,
width: width * 2, // 2x for quality on large screens width: width * 2, // 2x for quality on large screens
}); });
}, [api, item, orientation, width, imageUrlGetter]); }, [api, item, orientation, width, imageUrlGetter, preferEpisodeImage]);
// Progress calculation // Progress calculation
const progress = useMemo(() => { const progress = useMemo(() => {

View File

@@ -137,7 +137,11 @@
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"merge_next_up_continue_watching": "Merge Continue watching & Next up", "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": "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_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", "show_series_poster_on_episode": "Show series poster on episodes",
@@ -296,6 +300,7 @@
}, },
"safe_area_in_controls": "Safe area in controls", "safe_area_in_controls": "Safe area in controls",
"show_custom_menu_links": "Show custom menu links", "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)", "show_large_home_carousel": "Show large home carousel (beta)",
"hide_libraries": "Hide libraries", "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.", "select_libraries_you_want_to_hide": "Select the libraries you want to hide from the Library tab and home page sections.",

View File

@@ -274,6 +274,9 @@ export type Settings = {
hideBrightnessSlider: boolean; hideBrightnessSlider: boolean;
usePopularPlugin: boolean; usePopularPlugin: boolean;
mergeNextUpAndContinueWatching: 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 // TV-specific settings
showHomeBackdrop: boolean; showHomeBackdrop: boolean;
showTVHeroCarousel: boolean; showTVHeroCarousel: boolean;
@@ -382,6 +385,7 @@ export const defaultValues: Settings = {
hideBrightnessSlider: false, hideBrightnessSlider: false,
usePopularPlugin: true, usePopularPlugin: true,
mergeNextUpAndContinueWatching: false, mergeNextUpAndContinueWatching: false,
useEpisodeImagesForNextUp: false,
// TV-specific settings // TV-specific settings
showHomeBackdrop: true, showHomeBackdrop: true,
showTVHeroCarousel: true, showTVHeroCarousel: true,