mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-16 01:13:27 +01:00
Compare commits
4 Commits
feat/andro
...
feat/episo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
820604180d | ||
|
|
2e79815d78 | ||
|
|
43ce50fe70 | ||
|
|
bd0eef0407 |
@@ -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}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function AppearanceHideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function HideLibrariesPage() {
|
|||||||
))}
|
))}
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
{t("home.settings.other.select_libraries_you_want_to_hide")}
|
||||||
</Text>
|
</Text>
|
||||||
</DisabledSetting>
|
</DisabledSetting>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1318,7 +1318,7 @@ export default function DirectPlayerPage() {
|
|||||||
console.error("Video Error:", e.nativeEvent);
|
console.error("Video Error:", e.nativeEvent);
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("player.error"),
|
t("player.error"),
|
||||||
t("player.an_error_occured_while_playing_the_video"),
|
t("player.an_error_occurred_while_playing_the_video"),
|
||||||
);
|
);
|
||||||
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
writeToLog("ERROR", "Video Error", e.nativeEvent);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ configureReanimatedLogger({
|
|||||||
if (!Platform.isTV) {
|
if (!Platform.isTV) {
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
@@ -350,9 +351,12 @@ function Layout() {
|
|||||||
notificationListener.current =
|
notificationListener.current =
|
||||||
Notifications?.addNotificationReceivedListener(
|
Notifications?.addNotificationReceivedListener(
|
||||||
(notification: Notification) => {
|
(notification: Notification) => {
|
||||||
|
// Log only the title — serializing the whole notification touches
|
||||||
|
// the deprecated dataString getter (deprecation warning) and dumps
|
||||||
|
// noisy payloads into the console.
|
||||||
console.log(
|
console.log(
|
||||||
"Notification received while app running",
|
"Notification received while app running:",
|
||||||
notification,
|
notification.request.content.title,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Keyed on SeriesId so recycled FlashList cells re-read the correct poster
|
||||||
|
// instead of freezing the first-rendered series' image (empty deps bug).
|
||||||
const base64Image = useMemo(() => {
|
const base64Image = useMemo(() => {
|
||||||
return storage.getString(items[0].SeriesId!);
|
const seriesId = items[0]?.SeriesId;
|
||||||
}, []);
|
return seriesId ? storage.getString(seriesId) : undefined;
|
||||||
|
}, [items[0]?.SeriesId]);
|
||||||
|
|
||||||
const deleteSeries = useCallback(
|
const deleteSeries = useCallback(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const Login: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -499,7 +499,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occured");
|
: t("login.an_unexpected_error_occurred");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -523,7 +523,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("login.connection_failed"),
|
t("login.connection_failed"),
|
||||||
t("login.an_unexpected_error_occured"),
|
t("login.an_unexpected_error_occurred"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -768,7 +768,7 @@ export const TVLogin: React.FC = () => {
|
|||||||
const message =
|
const message =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: t("login.an_unexpected_error_occured");
|
: t("login.an_unexpected_error_occurred");
|
||||||
Alert.alert(t("login.connection_failed"), message);
|
Alert.alert(t("login.connection_failed"), message);
|
||||||
goToQRScreen();
|
goToQRScreen();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const QuickConnect: React.FC<Props> = ({ ...props }) => {
|
|||||||
successHapticFeedback();
|
successHapticFeedback();
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t("home.settings.quick_connect.success"),
|
t("home.settings.quick_connect.success"),
|
||||||
t("home.settings.quick_connect.quick_connect_autorized"),
|
t("home.settings.quick_connect.quick_connect_authorized"),
|
||||||
);
|
);
|
||||||
setQuickConnectCode(undefined);
|
setQuickConnectCode(undefined);
|
||||||
bottomSheetModalRef?.current?.close();
|
bottomSheetModalRef?.current?.close();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View } from "react-native";
|
import { Alert, Platform, View } from "react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
@@ -12,6 +12,7 @@ import { ListItem } from "../list/ListItem";
|
|||||||
export const StorageSettings = () => {
|
export const StorageSettings = () => {
|
||||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const successHapticFeedback = useHaptic("success");
|
const successHapticFeedback = useHaptic("success");
|
||||||
const errorHapticFeedback = useHaptic("error");
|
const errorHapticFeedback = useHaptic("error");
|
||||||
|
|
||||||
@@ -27,16 +28,38 @@ export const StorageSettings = () => {
|
|||||||
used: (app.total - app.remaining) / app.total,
|
used: (app.total - app.remaining) / app.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// Keep the bar moving while a download is writing to disk.
|
||||||
|
refetchInterval: 10 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDeleteClicked = async () => {
|
const onDeleteClicked = () => {
|
||||||
try {
|
Alert.alert(
|
||||||
await deleteAllFiles();
|
t("home.settings.storage.delete_all_downloaded_files_confirm"),
|
||||||
successHapticFeedback();
|
t("home.settings.storage.delete_all_downloaded_files_confirm_desc"),
|
||||||
} catch (_e) {
|
[
|
||||||
errorHapticFeedback();
|
{
|
||||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
text: t("common.cancel"),
|
||||||
}
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.ok"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
try {
|
||||||
|
await deleteAllFiles();
|
||||||
|
successHapticFeedback();
|
||||||
|
} catch (_e) {
|
||||||
|
errorHapticFeedback();
|
||||||
|
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||||
|
} finally {
|
||||||
|
// Reflect the freed space immediately instead of waiting for
|
||||||
|
// the next poll.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculatePercentage = (value: number, total: number) => {
|
const calculatePercentage = (value: number, total: number) => {
|
||||||
|
|||||||
@@ -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)
|
||||||
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
if (item.ParentBackdropItemId && item.ParentThumbImageTag) {
|
||||||
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
return `${api.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=700&quality=80&tag=${item.ParentThumbImageTag}`;
|
||||||
@@ -173,7 +181,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(() => {
|
||||||
|
|||||||
@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
|
|||||||
*/
|
*/
|
||||||
export function calculateTotalDownloadedSize(): number {
|
export function calculateTotalDownloadedSize(): number {
|
||||||
const items = getAllDownloadedItems();
|
const items = getAllDownloadedItems();
|
||||||
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
|
return items.reduce((sum, item) => {
|
||||||
|
// Trickplay bytes count too — getDownloadedItemSize models per-item size
|
||||||
|
// as video + trickplay, the total must match.
|
||||||
|
const trickplaySize = item.trickPlayData?.size ?? 0;
|
||||||
|
// Read the live file size on disk so the total reflects actual usage and
|
||||||
|
// self-heals items whose stored videoFileSize is 0 (old schema, or
|
||||||
|
// `fileInfo.size` was undefined at download time). Fall back to the stored
|
||||||
|
// value if the file can't be stat'd.
|
||||||
|
if (item.videoFilePath) {
|
||||||
|
try {
|
||||||
|
const file = new File(filePathToUri(item.videoFilePath));
|
||||||
|
if (file.exists) {
|
||||||
|
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to stat downloaded file for size:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum + (item.videoFileSize ?? 0) + trickplaySize;
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,24 @@ export function useDownloadOperations({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const appSizeUsage = useCallback(async () => {
|
const appSizeUsage = useCallback(async () => {
|
||||||
const totalSize = calculateTotalDownloadedSize();
|
let totalSize = calculateTotalDownloadedSize();
|
||||||
|
|
||||||
|
// Also count in-progress downloads (they write straight to their final
|
||||||
|
// path) so the growing file shows up as app usage instead of drifting
|
||||||
|
// into the generic device share until completion.
|
||||||
|
for (const process of processes) {
|
||||||
|
try {
|
||||||
|
const file = new File(
|
||||||
|
Paths.document,
|
||||||
|
`${generateFilename(process.item)}.mp4`,
|
||||||
|
);
|
||||||
|
if (file.exists) {
|
||||||
|
totalSize += file.size ?? 0;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File not created yet — ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
|
||||||
@@ -310,7 +327,7 @@ export function useDownloadOperations({
|
|||||||
appSize: totalSize,
|
appSize: totalSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, []);
|
}, [processes]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startBackgroundDownload,
|
startBackgroundDownload,
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
t(
|
t(
|
||||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
|
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,14 @@
|
|||||||
"got_it": "Got it",
|
"got_it": "Got it",
|
||||||
"connection_failed": "Connection failed",
|
"connection_failed": "Connection failed",
|
||||||
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
"could_not_connect_to_server": "Could not connect to the server. Please check the URL and your network connection.",
|
||||||
"an_unexpected_error_occured": "An unexpected error occurred",
|
"an_unexpected_error_occurred": "An unexpected error occurred",
|
||||||
"change_server": "Change server",
|
"change_server": "Change server",
|
||||||
"invalid_username_or_password": "Invalid username or password",
|
"invalid_username_or_password": "Invalid username or password",
|
||||||
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
"user_does_not_have_permission_to_log_in": "User does not have permission to log in",
|
||||||
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
"server_is_taking_too_long_to_respond_try_again_later": "Server is taking too long to respond, try again later",
|
||||||
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
"server_received_too_many_requests_try_again_later": "Server received too many requests, try again later.",
|
||||||
"there_is_a_server_error": "There is a server error",
|
"there_is_a_server_error": "There is a server error",
|
||||||
"an_unexpected_error_occured_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
"an_unexpected_error_occurred_did_you_enter_the_correct_url": "An unexpected error occurred. Did you enter the server URL correctly?",
|
||||||
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
"too_old_server_text": "Unsupported Jellyfin server discovered",
|
||||||
"too_old_server_description": "Please update Jellyfin to the latest version"
|
"too_old_server_description": "Please update Jellyfin to the latest version"
|
||||||
},
|
},
|
||||||
@@ -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",
|
||||||
@@ -188,7 +192,7 @@
|
|||||||
"authorize_button": "Authorize Quick Connect",
|
"authorize_button": "Authorize Quick Connect",
|
||||||
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
"enter_the_quick_connect_code": "Enter the Quick Connect code...",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"quick_connect_autorized": "Quick Connect authorized",
|
"quick_connect_authorized": "Quick Connect authorized",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"invalid_code": "Invalid code",
|
"invalid_code": "Invalid code",
|
||||||
"authorize": "Authorize"
|
"authorize": "Authorize"
|
||||||
@@ -296,9 +300,10 @@
|
|||||||
},
|
},
|
||||||
"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_liraries_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.",
|
||||||
"disable_haptic_feedback": "Disable haptic feedback",
|
"disable_haptic_feedback": "Disable haptic feedback",
|
||||||
"default_quality": "Default quality",
|
"default_quality": "Default quality",
|
||||||
"default_playback_speed": "Default playback speed",
|
"default_playback_speed": "Default playback speed",
|
||||||
@@ -384,6 +389,8 @@
|
|||||||
"device_usage": "Device {{availableSpace}}%",
|
"device_usage": "Device {{availableSpace}}%",
|
||||||
"size_used": "{{used}} of {{total}} used",
|
"size_used": "{{used}} of {{total}} used",
|
||||||
"delete_all_downloaded_files": "Delete all downloaded files",
|
"delete_all_downloaded_files": "Delete all downloaded files",
|
||||||
|
"delete_all_downloaded_files_confirm": "Delete All Downloaded Files?",
|
||||||
|
"delete_all_downloaded_files_confirm_desc": "Are you sure you want to delete all downloaded files? This action cannot be undone.",
|
||||||
"music_cache_title": "Music cache",
|
"music_cache_title": "Music cache",
|
||||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||||
"clear_music_cache": "Clear music cache",
|
"clear_music_cache": "Clear music cache",
|
||||||
@@ -598,7 +605,7 @@
|
|||||||
"mpv_player_title": "MPV player",
|
"mpv_player_title": "MPV player",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"failed_to_get_stream_url": "Failed to get the stream URL",
|
"failed_to_get_stream_url": "Failed to get the stream URL",
|
||||||
"an_error_occured_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
"an_error_occurred_while_playing_the_video": "An error occurred while playing the video. Check logs in settings.",
|
||||||
"client_error": "Client error",
|
"client_error": "Client error",
|
||||||
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
"could_not_create_stream_for_chromecast": "Could not create a stream for Chromecast",
|
||||||
"message_from_server": "Message from server: {{message}}",
|
"message_from_server": "Message from server: {{message}}",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user