Compare commits

..

2 Commits

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

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

@@ -92,7 +92,7 @@
"react-native-uuid": "^2.0.3", "react-native-uuid": "^2.0.3",
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.8.3", "react-native-worklets": "0.11.0",
"sonner-native": "0.21.2", "sonner-native": "0.21.2",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
@@ -1653,7 +1653,7 @@
"react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="], "react-native-web": ["react-native-web@0.21.2", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", "fbjs": "^3.0.4", "inline-style-prefixer": "^7.0.1", "memoize-one": "^6.0.0", "nullthrows": "^1.1.1", "postcss-value-parser": "^4.2.0", "styleq": "^0.1.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg=="],
"react-native-worklets": ["react-native-worklets@0.8.3", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-classes": "^7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.81 - 0.85" } }, "sha512-oCBJROyLU7yG/1R8s0INMflygTH71bx+5XcYkH0CM938TlhSoVbiunE1WVW5FZa51vwYqfLie/IXMX2s1Kh3eg=="], "react-native-worklets": ["react-native-worklets@0.11.0", "", { "dependencies": { "@babel/generator": "^7.27.1", "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", "@babel/plugin-transform-classes": "^7.28.6", "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", "@babel/plugin-transform-optional-chaining": "^7.28.6", "@babel/plugin-transform-shorthand-properties": "^7.27.1", "@babel/plugin-transform-template-literals": "^7.27.1", "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/preset-typescript": "^7.28.5", "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1", "convert-source-map": "^2.0.0", "semver": "^7.7.4" }, "peerDependencies": { "@babel/core": "*", "@react-native/metro-config": "*", "react": "*", "react-native": "0.83 - 0.86" } }, "sha512-mNbNxqbeGUEPKH8o15wieKH6+OxNikWIV/LHlP8qU6YD+FkA8reUA98ptDVboNCtCfiE/duEIZO+PC0qsHxC3Q=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],

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

@@ -15,6 +15,7 @@ import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { chapterMarkers, chapterNameAt } from "@/utils/chapters"; import { chapterMarkers, chapterNameAt } from "@/utils/chapters";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton"; import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { TimeDisplay } from "./TimeDisplay"; import { TimeDisplay } from "./TimeDisplay";
import { TrickplayBubble } from "./TrickplayBubble"; import { TrickplayBubble } from "./TrickplayBubble";
@@ -33,8 +34,11 @@ interface BottomControlsProps {
showRemoteBubble: boolean; showRemoteBubble: boolean;
currentTime: number; currentTime: number;
remainingTime: number; remainingTime: number;
showSkipButton: boolean;
showSkipCreditButton: boolean; showSkipCreditButton: boolean;
hasContentAfterCredits: boolean; hasContentAfterCredits: boolean;
skipIntro: () => void;
skipCredit: () => void;
nextItem?: BaseItemDto | null; nextItem?: BaseItemDto | null;
handleNextEpisodeAutoPlay: () => void; handleNextEpisodeAutoPlay: () => void;
handleNextEpisodeManual: () => void; handleNextEpisodeManual: () => void;
@@ -82,8 +86,11 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble, showRemoteBubble,
currentTime, currentTime,
remainingTime, remainingTime,
showSkipButton,
showSkipCreditButton, showSkipCreditButton,
hasContentAfterCredits, hasContentAfterCredits,
skipIntro,
skipCredit,
nextItem, nextItem,
handleNextEpisodeAutoPlay, handleNextEpisodeAutoPlay,
handleNextEpisodeManual, handleNextEpisodeManual,
@@ -173,11 +180,21 @@ export const BottomControls: FC<BottomControlsProps> = ({
) : null} ) : null}
</View> </View>
<View className='flex flex-row items-center space-x-2 shrink-0'> <View className='flex flex-row items-center space-x-2 shrink-0'>
{/* Smart Skip Credits behavior (drives Next Episode timing): <SkipButton
- "Skip Credits" button itself is rendered in SkipSegmentOverlay showButton={showSkipButton}
(independent of controls visibility). onPress={skipIntro}
- "Next Episode" shows when credits extend to video end AND a next buttonText='Skip Intro'
episode exists. */} />
{/* Smart Skip Credits behavior:
- Show "Skip Credits" if there's content after credits OR no next episode
- Show "Next Episode" if credits extend to video end AND next episode exists */}
<SkipButton
showButton={
showSkipCreditButton && (hasContentAfterCredits || !nextItem)
}
onPress={skipCredit}
buttonText='Skip Credits'
/>
{settings.autoPlayNextEpisode !== false && {settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 || (settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.autoPlayEpisodeCount <

View File

@@ -38,7 +38,6 @@ import { useRemoteControl } from "./hooks/useRemoteControl";
import { useVideoNavigation } from "./hooks/useVideoNavigation"; import { useVideoNavigation } from "./hooks/useVideoNavigation";
import { useVideoSlider } from "./hooks/useVideoSlider"; import { useVideoSlider } from "./hooks/useVideoSlider";
import { useVideoTime } from "./hooks/useVideoTime"; import { useVideoTime } from "./hooks/useVideoTime";
import { SkipSegmentOverlay } from "./SkipSegmentOverlay";
import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay"; import { TechnicalInfoOverlay } from "./TechnicalInfoOverlay";
import { useControlsTimeout } from "./useControlsTimeout"; import { useControlsTimeout } from "./useControlsTimeout";
import { PlaybackSpeedScope } from "./utils/playback-speed-settings"; import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
@@ -339,16 +338,6 @@ export const Controls: FC<Props> = ({
maxMs, maxMs,
); );
// Whether the "Next Episode" countdown will actually be rendered. The Skip
// Credits button yields to it only when this is true; if autoplay is
// disabled or its episode limit is reached, Skip Credits must stay available
// (mirrors the NextEpisodeCountDownButton mount gate in BottomControls).
const willShowNextEpisode =
!!nextItem &&
settings.autoPlayNextEpisode !== false &&
(settings.maxAutoPlayEpisodeCount.value === -1 ||
settings.autoPlayEpisodeCount < settings.maxAutoPlayEpisodeCount.value);
const goToItemCommon = useCallback( const goToItemCommon = useCallback(
(item: BaseItemDto) => { (item: BaseItemDto) => {
if (!item || !settings) { if (!item || !settings) {
@@ -581,8 +570,11 @@ export const Controls: FC<Props> = ({
showRemoteBubble={showRemoteBubble} showRemoteBubble={showRemoteBubble}
currentTime={currentTime} currentTime={currentTime}
remainingTime={remainingTime} remainingTime={remainingTime}
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton} showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits} hasContentAfterCredits={hasContentAfterCredits}
skipIntro={skipIntro}
skipCredit={skipCredit}
nextItem={nextItem} nextItem={nextItem}
handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay} handleNextEpisodeAutoPlay={handleNextEpisodeAutoPlay}
handleNextEpisodeManual={handleNextEpisodeManual} handleNextEpisodeManual={handleNextEpisodeManual}
@@ -602,17 +594,6 @@ export const Controls: FC<Props> = ({
time={isSliding || showRemoteBubble ? time : remoteTime} time={isSliding || showRemoteBubble ? time : remoteTime}
/> />
</Animated.View> </Animated.View>
{/* Skip Intro / Skip Credits float independently of the controls so
they're visible (and tappable) without summoning the controls. */}
<SkipSegmentOverlay
showSkipButton={showSkipButton}
showSkipCreditButton={showSkipCreditButton}
hasContentAfterCredits={hasContentAfterCredits}
willShowNextEpisode={willShowNextEpisode}
skipIntro={skipIntro}
skipCredit={skipCredit}
controlsVisible={showControls}
/>
</> </>
)} )}
{settings.maxAutoPlayEpisodeCount.value !== -1 && ( {settings.maxAutoPlayEpisodeCount.value !== -1 && (

View File

@@ -1,125 +0,0 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
import { StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useControlsSafeAreaInsets } from "@/hooks/useControlsSafeAreaInsets";
import SkipButton from "./SkipButton";
interface Props {
showSkipButton: boolean;
showSkipCreditButton: boolean;
hasContentAfterCredits: boolean;
willShowNextEpisode: boolean;
skipIntro: () => void;
skipCredit: () => void;
controlsVisible: boolean;
}
// Offsets are relative to the safe-area insets so they hold in both portrait
// and landscape (the insets move with the notch / home indicator).
//
// Hidden: low, far-right — nothing else is drawn there, this is the familiar
// spot and was working fine.
// Visible: shifted up (clear of the horizontal progress bar) and left (clear
// of the chapters icon), while staying below the vertical volume
// slider which sits at the vertical middle of the screen.
const HIDDEN_BOTTOM = 24;
const HIDDEN_RIGHT = 12;
const VISIBLE_BOTTOM = 65;
const VISIBLE_RIGHT = 42;
const ANIM_DURATION = 250;
// Keeps `value` true for `duration` ms after it turns false. SkipButton hides
// itself instantly via a `hidden` (display:none) class, which would preempt
// the parent's opacity fade-out — lagging the flag keeps the button rendered
// (and visible) while the fade plays out.
const useDelayedHide = (value: boolean, duration: number): boolean => {
const [display, setDisplay] = useState(value);
useEffect(() => {
if (value) {
setDisplay(true);
return;
}
const t = setTimeout(() => setDisplay(false), duration);
return () => clearTimeout(t);
}, [value, duration]);
return value || display;
};
/**
* Floating Skip Intro / Skip Credits buttons shown independently of the
* player controls. They appear on their own during an intro or credits segment
* without the user having to summon the controls.
*/
export const SkipSegmentOverlay: FC<Props> = ({
showSkipButton,
showSkipCreditButton,
hasContentAfterCredits,
willShowNextEpisode,
skipIntro,
skipCredit,
controlsVisible,
}) => {
const insets = useControlsSafeAreaInsets();
const showCredit =
showSkipCreditButton && (hasContentAfterCredits || !willShowNextEpisode);
const visible = showSkipButton || showCredit;
// Drive each SkipButton with a lagged flag so it stays visible while the
// opacity fade-out plays, instead of disappearing the instant its segment
// ends. `visible` above still drives opacity/pointerEvents immediately.
const renderSkip = useDelayedHide(showSkipButton, ANIM_DURATION);
const renderCredit = useDelayedHide(showCredit, ANIM_DURATION);
const opacity = useSharedValue(visible ? 1 : 0);
useEffect(() => {
opacity.value = withTiming(visible ? 1 : 0, {
duration: ANIM_DURATION,
easing: Easing.out(Easing.quad),
});
}, [visible, opacity]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
// Position is recomputed on render (no slide animation) so the button never
// sweeps through an overlap zone while the controls toggle.
const bottom =
insets.bottom + (controlsVisible ? VISIBLE_BOTTOM : HIDDEN_BOTTOM);
const right = insets.right + (controlsVisible ? VISIBLE_RIGHT : HIDDEN_RIGHT);
return (
<Animated.View
style={[styles.container, { right, bottom }, animatedStyle]}
pointerEvents={visible ? "box-none" : "none"}
>
<SkipButton
showButton={renderSkip}
onPress={skipIntro}
buttonText='Skip Intro'
/>
<SkipButton
showButton={renderCredit}
onPress={skipCredit}
buttonText='Skip Credits'
/>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: "absolute",
flexDirection: "row",
gap: 8,
zIndex: 15,
},
});

View File

@@ -115,7 +115,7 @@
"react-native-uuid": "^2.0.3", "react-native-uuid": "^2.0.3",
"react-native-volume-manager": "^2.0.8", "react-native-volume-manager": "^2.0.8",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-worklets": "0.8.3", "react-native-worklets": "0.11.0",
"sonner-native": "0.21.2", "sonner-native": "0.21.2",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",

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,