Compare commits

..

4 Commits

Author SHA1 Message Date
lance chant
980245f19d Merge branch 'develop' into fix/skip-button-rework 2026-07-17 14:47:48 +02:00
lance chant
228e847a4e Update components/video-player/controls/SkipSegmentOverlay.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-17 11:51:37 +02:00
Lance Chant
15c66bc714 Addressing PR comments
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:45:42 +02:00
Lance Chant
c4209cbf8e fix: fixing the skip button
Allowing it to show independently of the controls
Fixing the position of the button to fit over things better

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
2026-07-17 11:02:23 +02:00
16 changed files with 280 additions and 380 deletions

View File

@@ -854,13 +854,6 @@ 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

@@ -11,7 +11,6 @@ import { Image } from "expo-image";
import { DarkTheme, ThemeProvider } from "expo-router/react-navigation"; import { DarkTheme, ThemeProvider } from "expo-router/react-navigation";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { GlobalModal } from "@/components/GlobalModal"; import { GlobalModal } from "@/components/GlobalModal";
import { PendingAccountSaveModal } from "@/components/PendingAccountSaveModal";
import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler"; import { enableTVMenuKeyInterception } from "@/hooks/useTVBackHandler";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -552,7 +551,6 @@ function Layout() {
closeButton closeButton
/> />
{!Platform.isTV && <GlobalModal />} {!Platform.isTV && <GlobalModal />}
{!Platform.isTV && <PendingAccountSaveModal />}
</ThemeProvider> </ThemeProvider>
</IntroSheetProvider> </IntroSheetProvider>
</BottomSheetModalProvider> </BottomSheetModalProvider>

View File

@@ -1,52 +0,0 @@
import { useAtom, useAtomValue } from "jotai";
import type React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { toast } from "sonner-native";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import {
pendingAccountSaveAtom,
useJellyfin,
userAtom,
} from "@/providers/JellyfinProvider";
import { writeErrorLog } from "@/utils/log";
/**
* Post-login save-account prompt. Login flows (password or Quick Connect)
* only flag the intent via pendingAccountSaveAtom; the protection picker
* shows here, AFTER the session is authorized — the login screen itself
* unmounts as soon as the user is set, so it can't host the modal.
*/
export const PendingAccountSaveModal: React.FC = () => {
const [pending, setPending] = useAtom(pendingAccountSaveAtom);
const user = useAtomValue(userAtom);
const { saveCurrentAccount } = useJellyfin();
const { t } = useTranslation();
// A logout before answering drops the intent — it must not resurface on
// the next (possibly different) login.
useEffect(() => {
if (!user && pending) setPending(null);
}, [user, pending, setPending]);
if (Platform.isTV) return null;
return (
<SaveAccountModal
visible={!!pending && !!user}
username={user?.Name ?? ""}
onClose={() => setPending(null)}
onSave={(securityType, pinCode) => {
const serverName = pending?.serverName;
setPending(null);
saveCurrentAccount({ securityType, pinCode, serverName }).catch(
(error) => {
writeErrorLog(`Failed to save account: ${error?.message ?? error}`);
toast.error(t("save_account.not_saved"));
},
);
}}
/>
);
};

View File

@@ -39,11 +39,7 @@ import { useRefreshLibraryOnFocus } from "@/hooks/useRefreshLibraryOnFocus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { useIntroSheet } from "@/providers/IntroSheetProvider";
import { import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
apiAtom,
pendingAccountSaveAtom,
userAtom,
} from "@/providers/JellyfinProvider";
import { SortByOption, SortOrderOption } from "@/utils/atoms/filters"; import { SortByOption, SortOrderOption } from "@/utils/atoms/filters";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
@@ -93,9 +89,6 @@ const HomeMobile = () => {
const invalidateCache = useInvalidatePlaybackProgressCache(); const invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set()); const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
const { showIntro } = useIntroSheet(); const { showIntro } = useIntroSheet();
// Gate the intro so it can't steal presentation from the post-login
// save-account sheet (both are BottomSheetModals): wait until no save is pending.
const pendingAccountSave = useAtomValue(pendingAccountSaveAtom);
// Fallback refresh for newly added content when returning to the home screen // Fallback refresh for newly added content when returning to the home screen
// (primary path is the LibraryChanged WebSocket event). // (primary path is the LibraryChanged WebSocket event).
@@ -104,9 +97,7 @@ const HomeMobile = () => {
// Show intro modal on first launch // Show intro modal on first launch
useEffect(() => { useEffect(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro"); const hasShownIntro = storage.getBoolean("hasShownIntro");
// Defer while the save-account sheet is up; this effect re-runs and schedules if (!hasShownIntro) {
// the intro once the sheet is dismissed (pendingAccountSaveAtom cleared).
if (!hasShownIntro && !pendingAccountSave) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
showIntro(); showIntro();
}, 1000); }, 1000);
@@ -115,7 +106,7 @@ const HomeMobile = () => {
clearTimeout(timer); clearTimeout(timer);
}; };
} }
}, [showIntro, pendingAccountSave]); }, [showIntro]);
useEffect(() => { useEffect(() => {
if (isConnected && !prevIsConnected.current) { if (isConnected && !prevIsConnected.current) {

View File

@@ -15,7 +15,6 @@ 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";
@@ -86,7 +85,6 @@ 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(() => {
@@ -188,10 +186,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
`} `}
> >
{item.Type === "Episode" && orientation === "horizontal" && ( {item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster <ContinueWatchingPoster item={item} />
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)} )}
{item.Type === "Episode" && orientation === "vertical" && ( {item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} /> <SeriesPoster item={item} />

View File

@@ -24,7 +24,6 @@ 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
@@ -166,7 +165,6 @@ 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() ?? [];
@@ -233,7 +231,6 @@ 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>
); );
@@ -246,7 +243,6 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
showItemActions, showItemActions,
handleItemFocus, handleItemFocus,
ITEM_GAP, ITEM_GAP,
settings?.useEpisodeImagesForNextUp,
], ],
); );

View File

@@ -9,7 +9,6 @@ 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";
@@ -51,7 +50,6 @@ 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);
@@ -110,10 +108,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
`} `}
> >
{item.Type === "Episode" && orientation === "horizontal" && ( {item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster <ContinueWatchingPoster item={item} />
item={item}
useEpisodePoster={settings?.useEpisodeImagesForNextUp}
/>
)} )}
{item.Type === "Episode" && orientation === "vertical" && ( {item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} /> <SeriesPoster item={item} />

View File

@@ -3,7 +3,7 @@ import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
@@ -21,14 +21,13 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery"; import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList"; import { PreviousServersList } from "@/components/PreviousServersList";
import { SaveAccountModal } from "@/components/SaveAccountModal";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
apiAtom, import type {
pendingAccountSaveAtom, AccountSecurityType,
useJellyfin, SavedServer,
userAtom, } from "@/utils/secureCredentials";
} from "@/providers/JellyfinProvider";
import type { SavedServer } from "@/utils/secureCredentials";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, t("login.username_required")),
@@ -36,7 +35,6 @@ const CredentialsSchema = z.object({
export const Login: React.FC = () => { export const Login: React.FC = () => {
const api = useAtomValue(apiAtom); const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const navigation = useNavigation(); const navigation = useNavigation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { const {
@@ -47,7 +45,6 @@ export const Login: React.FC = () => {
loginWithSavedCredential, loginWithSavedCredential,
loginWithPassword, loginWithPassword,
} = useJellyfin(); } = useJellyfin();
const setPendingAccountSave = useSetAtom(pendingAccountSaveAtom);
const { const {
apiUrl: _apiUrl, apiUrl: _apiUrl,
@@ -67,24 +64,13 @@ export const Login: React.FC = () => {
password: _password || "", password: _password || "",
}); });
// Save account state — only the intent lives here; the protection picker is // Save account state
// the global PendingAccountSaveModal, shown after the login succeeds.
const [saveAccount, setSaveAccount] = useState(false); const [saveAccount, setSaveAccount] = useState(false);
const [showSaveModal, setShowSaveModal] = useState(false);
// Tracks an in-flight Quick Connect attempt (code issued, provider polling). const [pendingLogin, setPendingLogin] = useState<{
const [quickConnectActive, setQuickConnectActive] = useState(false); username: string;
password: string;
// A Quick Connect login with "save account" on flags the post-login save: } | null>(null);
// the protection picker shows globally once the session exists (this screen
// unmounts on login, so it can't host the modal).
useEffect(() => {
if (user) {
if (quickConnectActive && saveAccount) {
setPendingAccountSave({ serverName });
}
setQuickConnectActive(false);
}
}, [user]);
// Handle URL params for server connection // Handle URL params for server connection
useEffect(() => { useEffect(() => {
@@ -131,22 +117,29 @@ export const Login: React.FC = () => {
const result = CredentialsSchema.safeParse(credentials); const result = CredentialsSchema.safeParse(credentials);
if (!result.success) return; if (!result.success) return;
const ok = await performLogin(credentials.username, credentials.password); if (saveAccount) {
// The protection picker shows AFTER a successful login (global modal) — setPendingLogin({
// never for a failed one. username: credentials.username,
if (ok && saveAccount) { password: credentials.password,
setPendingAccountSave({ serverName }); });
setShowSaveModal(true);
} else {
await performLogin(credentials.username, credentials.password);
} }
}; };
const performLogin = async ( const performLogin = async (
username: string, username: string,
password: string, password: string,
): Promise<boolean> => { options?: {
saveAccount?: boolean;
securityType?: AccountSecurityType;
pinCode?: string;
},
) => {
setLoading(true); setLoading(true);
try { try {
await login(username, password, serverName); await login(username, password, serverName, options);
return true;
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); Alert.alert(t("login.connection_failed"), error.message);
@@ -156,9 +149,23 @@ export const Login: React.FC = () => {
t("login.an_unexpected_error_occurred"), t("login.an_unexpected_error_occurred"),
); );
} }
return false;
} finally { } finally {
setLoading(false); setLoading(false);
setPendingLogin(null);
}
};
const handleSaveAccountConfirm = async (
securityType: AccountSecurityType,
pinCode?: string,
) => {
setShowSaveModal(false);
if (pendingLogin) {
await performLogin(pendingLogin.username, pendingLogin.password, {
saveAccount: true,
securityType,
pinCode,
});
} }
}; };
@@ -252,7 +259,6 @@ export const Login: React.FC = () => {
try { try {
const code = await initiateQuickConnect(); const code = await initiateQuickConnect();
if (code) { if (code) {
setQuickConnectActive(true);
Alert.alert( Alert.alert(
t("login.quick_connect"), t("login.quick_connect"),
t("login.enter_code_to_login", { code: code }), t("login.enter_code_to_login", { code: code }),
@@ -437,6 +443,16 @@ export const Login: React.FC = () => {
</View> </View>
)} )}
</KeyboardAvoidingView> </KeyboardAvoidingView>
<SaveAccountModal
visible={showSaveModal}
onClose={() => {
setShowSaveModal(false);
setPendingLogin(null);
}}
onSave={handleSaveAccountConfirm}
username={pendingLogin?.username || credentials.username}
/>
</SafeAreaView> </SafeAreaView>
); );
}; };

View File

@@ -27,7 +27,6 @@ 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(
@@ -45,9 +44,6 @@ 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}
@@ -56,32 +52,15 @@ 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,9 +72,6 @@ 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;
} }
/** /**
@@ -111,7 +108,6 @@ 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();
@@ -143,10 +139,6 @@ 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) {
@@ -182,7 +174,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, preferEpisodeImage]); }, [api, item, orientation, width, imageUrlGetter]);
// Progress calculation // Progress calculation
const progress = useMemo(() => { const progress = useMemo(() => {

View File

@@ -15,7 +15,6 @@ 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";
@@ -34,11 +33,8 @@ 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;
@@ -86,11 +82,8 @@ export const BottomControls: FC<BottomControlsProps> = ({
showRemoteBubble, showRemoteBubble,
currentTime, currentTime,
remainingTime, remainingTime,
showSkipButton,
showSkipCreditButton, showSkipCreditButton,
hasContentAfterCredits, hasContentAfterCredits,
skipIntro,
skipCredit,
nextItem, nextItem,
handleNextEpisodeAutoPlay, handleNextEpisodeAutoPlay,
handleNextEpisodeManual, handleNextEpisodeManual,
@@ -180,21 +173,11 @@ 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'>
<SkipButton {/* Smart Skip Credits behavior (drives Next Episode timing):
showButton={showSkipButton} - "Skip Credits" button itself is rendered in SkipSegmentOverlay
onPress={skipIntro} (independent of controls visibility).
buttonText='Skip Intro' - "Next Episode" shows when credits extend to video end AND a next
/> 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,6 +38,7 @@ 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";
@@ -338,6 +339,16 @@ 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) {
@@ -570,11 +581,8 @@ 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}
@@ -594,6 +602,17 @@ 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

@@ -0,0 +1,125 @@
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

@@ -15,14 +15,12 @@ import {
useContext, useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AppState, Platform } from "react-native"; import { AppState, Platform } from "react-native";
import { getDeviceNameSync } from "react-native-device-info"; import { getDeviceNameSync } from "react-native-device-info";
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
import { toast } from "sonner-native";
import useRouter from "@/hooks/useAppRouter"; import useRouter from "@/hooks/useAppRouter";
import { useInterval } from "@/hooks/useInterval"; import { useInterval } from "@/hooks/useInterval";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr"; import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
@@ -93,12 +91,6 @@ export const apiAtom = atom<Api | null>(initialApi);
export const userAtom = atom<UserDto | null>(initialUser); export const userAtom = atom<UserDto | null>(initialUser);
export const wsAtom = atom<WebSocket | null>(null); export const wsAtom = atom<WebSocket | null>(null);
export const cacheVersionAtom = atom<number>(0); export const cacheVersionAtom = atom<number>(0);
// Set by a login flow that wants the account saved: the protection picker
// shows AFTER the session is authorized (the login screen unmounts on
// success, so the modal lives at the root — see PendingAccountSaveModal).
export const pendingAccountSaveAtom = atom<{ serverName?: string } | null>(
null,
);
interface LoginOptions { interface LoginOptions {
saveAccount?: boolean; saveAccount?: boolean;
@@ -116,11 +108,6 @@ interface JellyfinContextValue {
serverName?: string, serverName?: string,
options?: LoginOptions, options?: LoginOptions,
) => Promise<void>; ) => Promise<void>;
saveCurrentAccount: (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>; initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void; stopQuickConnectPolling: () => void;
@@ -178,69 +165,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr(); const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// --- Session-expiry handling ----------------------------------------------
// When the server revokes the token (e.g. the device/session is deleted), a
// 401 can surface from any authenticated request. Without central handling
// the dead token stays in storage, so every reload re-fires authed calls →
// 401 spam + uncaught rejections, and the app lingers in a half-authenticated
// state. A single response interceptor on the authenticated api clears the
// session on the first 401 so the app drops cleanly to the login screen.
const sessionExpiredRef = useRef(false);
// Shared teardown for manual logout AND forced session expiry — keeping it
// in one place prevents the two paths from drifting (a 401 expiry must wipe
// plugin settings / Jellyseerr state too, or the next login on the same
// device inherits the previous user's data).
// Saved credentials are kept so the user can quick-login again.
const clearSessionState = useCallback(async () => {
// All synchronous teardown first: if the async Jellyseerr cleanup below
// fails or resolves late (user may already be re-authenticating), the
// session/cache state is already gone.
storage.remove("token");
storage.remove("user");
storage.remove("REACT_QUERY_OFFLINE_CACHE");
clearTVDiscoverySafely();
setUser(null);
setApi(null);
setPluginSettings(undefined);
queryClient.clear();
try {
await clearAllJellyseerData();
} catch (e) {
writeErrorLog(
`Failed to clear Jellyseerr data: ${e instanceof Error ? e.message : e}`,
);
}
}, [setUser, setApi, setPluginSettings, clearAllJellyseerData, queryClient]);
const handleSessionExpired = useCallback(() => {
if (sessionExpiredRef.current) return; // run once per session
sessionExpiredRef.current = true;
clearSessionState().catch((e) =>
writeErrorLog(`Session-expiry cleanup failed: ${e?.message ?? e}`),
);
}, [clearSessionState]);
useEffect(() => {
// Only guard an authenticated session. A pre-auth api (login screen) keeps
// its own handling — a wrong-password 401 is not a session expiry.
if (!api?.accessToken) return;
sessionExpiredRef.current = false; // re-arm for this fresh session
const interceptorId = api.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
handleSessionExpired();
}
return Promise.reject(error);
},
);
return () => {
api.axiosInstance.interceptors.response.eject(interceptorId);
};
}, [api, handleSessionExpired]);
const headers = useMemo(() => { const headers = useMemo(() => {
if (!deviceId) return {}; if (!deviceId) return {};
return { return {
@@ -383,40 +307,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}, },
}); });
// Persist the CURRENT session to secure storage — used by the post-login
// save-account modal (the protection picker shows AFTER a successful
// login, for both the password and Quick Connect flows).
const saveCurrentAccount = useCallback(
async (options?: {
securityType?: AccountSecurityType;
pinCode?: string;
serverName?: string;
}) => {
const token = storage.getString("token");
if (!api?.basePath || !user?.Id || !user.Name || !token) return;
const securityType = options?.securityType || "none";
let pinHash: string | undefined;
if (securityType === "pin") {
// Never persist a "pin" credential without its hash — it would be
// impossible to unlock.
if (!options?.pinCode) throw new Error("PIN code is required");
pinHash = await hashPIN(options.pinCode);
}
await saveAccountCredential({
serverUrl: api.basePath,
serverName: options?.serverName || "",
token,
userId: user.Id,
username: user.Name,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: user.PrimaryImageTag ?? undefined,
});
},
[api?.basePath, user],
);
const loginMutation = useMutation({ const loginMutation = useMutation({
mutationFn: async ({ mutationFn: async ({
username, username,
@@ -448,25 +338,18 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (securityType === "pin" && options.pinCode) { if (securityType === "pin" && options.pinCode) {
pinHash = await hashPIN(options.pinCode); pinHash = await hashPIN(options.pinCode);
} }
if (securityType === "pin" && !pinHash) {
// Never persist a "pin" credential without its hash — it would be await saveAccountCredential({
// impossible to unlock. Skip the save rather than failing a login serverUrl: api.basePath,
// that already succeeded, and tell the user it didn't happen. serverName: serverName || "",
writeErrorLog("Account save skipped: PIN required but missing"); token: auth.data.AccessToken,
toast.error(t("save_account.not_saved")); userId: auth.data.User.Id || "",
} else { username,
await saveAccountCredential({ savedAt: Date.now(),
serverUrl: api.basePath, securityType,
serverName: serverName || "", pinHash,
token: auth.data.AccessToken, primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
userId: auth.data.User.Id || "", });
username,
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
});
}
} }
const recentPluginSettings = await refreshStreamyfinPluginSettings(); const recentPluginSettings = await refreshStreamyfinPluginSettings();
@@ -526,7 +409,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
writeErrorLog("Failed to delete expo push token for device"), writeErrorLog("Failed to delete expo push token for device"),
); );
await clearSessionState(); storage.remove("token");
storage.remove("user");
clearTVDiscoverySafely();
setUser(null);
setApi(null);
setPluginSettings(undefined);
await clearAllJellyseerData();
// Clear React Query cache to prevent data from previous account lingering
queryClient.clear();
storage.remove("REACT_QUERY_OFFLINE_CACHE");
// Note: We keep saved credentials for quick switching back
}, },
onError: (error) => { onError: (error) => {
console.error("Logout failed:", error); console.error("Logout failed:", error);
@@ -614,9 +509,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
} }
}, },
onError: (error) => { onError: (error) => {
// Expected, handled case (e.g. revoked token → "Session Expired", or console.error("Quick login failed:", error);
// server unreachable): the UI surfaces the message, so warn, don't error.
console.warn("Quick login failed:", error);
}, },
}); });
@@ -727,66 +620,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser); setUser(storedUser);
} }
// Validate the token and refresh user data in the background. Do NOT // Dismiss splash screen with cached data immediately,
// await this: the Jellyfin SDK axios instance has no timeout, so when // fetch fresh user data in the background
// offline this call hangs for the full OS TCP timeout (75-120s) and setInitialLoaded(true);
// blocks splash dismissal. The cached storedUser (set above) is enough
// to render; on success we just refresh it.
getUserApi(apiInstance)
.getCurrentUser()
.then(async (response) => {
// The response can resolve long after startup (no axios timeout).
// If the session changed meanwhile (logout, account switch), drop
// it instead of repopulating a stale user / re-saving credentials.
if (getTokenFromStorage() !== token) return;
setUser(response.data);
// Migrate current session to secure storage if not already saved try {
if (storedUser?.Id && storedUser?.Name) { const response = await getUserApi(apiInstance).getCurrentUser();
const existingCredential = await getAccountCredential( setUser(response.data);
serverUrl,
storedUser.Id, // Migrate current session to secure storage if not already saved
); if (storedUser?.Id && storedUser?.Name) {
if (!existingCredential) { const existingCredential = await getAccountCredential(
await saveAccountCredential({ serverUrl,
serverUrl, storedUser.Id,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
})
.catch((e) => {
// Expected, handled case (offline, or a token the server rejects —
// the UI prompts re-login): warn, don't error. Log only
// status/message — never the raw error (axios errors carry the
// request config incl. the Authorization header / token).
console.warn(
"Background user validation failed:",
e?.response?.status ?? e?.message ?? "unknown error",
); );
}); if (!existingCredential) {
await saveAccountCredential({
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
} else if (
response.data.PrimaryImageTag !==
existingCredential.primaryImageTag
) {
// Update image tag if it has changed
addAccountToServer(serverUrl, existingCredential.serverName, {
userId: existingCredential.userId,
username: existingCredential.username,
securityType: existingCredential.securityType,
savedAt: existingCredential.savedAt,
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
});
}
}
} catch (e) {
// Background fetch failed — app already rendered with cached data
console.warn("Background user fetch failed, using cached data:", e);
}
} else {
setInitialLoaded(true);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally {
setInitialLoaded(true); setInitialLoaded(true);
} }
}; };
@@ -800,7 +681,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
removeServer: () => removeServerMutation.mutateAsync(), removeServer: () => removeServerMutation.mutateAsync(),
login: (username, password, serverName, options) => login: (username, password, serverName, options) =>
loginMutation.mutateAsync({ username, password, serverName, options }), loginMutation.mutateAsync({ username, password, serverName, options }),
saveCurrentAccount,
logout: () => logoutMutation.mutateAsync(), logout: () => logoutMutation.mutateAsync(),
initiateQuickConnect, initiateQuickConnect,
stopQuickConnectPolling, stopQuickConnectPolling,

View File

@@ -56,7 +56,6 @@
"save_account": { "save_account": {
"title": "Save account", "title": "Save account",
"save_for_later": "Save this account", "save_for_later": "Save this account",
"not_saved": "Account was not saved",
"security_option": "Security option", "security_option": "Security option",
"no_protection": "No protection", "no_protection": "No protection",
"no_protection_desc": "Quick login without authentication", "no_protection_desc": "Quick login without authentication",
@@ -138,11 +137,7 @@
"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",
@@ -301,7 +296,6 @@
}, },
"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,9 +274,6 @@ 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;
@@ -385,7 +382,6 @@ 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,