mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-29 17:20:30 +01:00
Compare commits
10 Commits
I10n_crowd
...
feat/tv-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ac98b597 | ||
|
|
304cb06e0d | ||
|
|
11d71af468 | ||
|
|
01fd552a0c | ||
|
|
427e70e7ef | ||
|
|
d8fcb801e1 | ||
|
|
913bd9b1da | ||
|
|
f33c777e0c | ||
|
|
eba08b412f | ||
|
|
bbef84132b |
@@ -6,4 +6,6 @@
|
||||
|
||||
## Detail
|
||||
|
||||
The `useNetworkAwareQueryClient` hook uses `Object.create(queryClient)` which breaks QueryClient methods that use JavaScript private fields (like `getQueriesData`, `setQueriesData`, `setQueryData`). Only use it when you ONLY need `invalidateQueries`. For cache manipulation, use standard `useQueryClient` from `@tanstack/react-query`.
|
||||
**Updated 2026-06-29**: This limitation no longer applies. The hook was rewritten to use a `Proxy` (not `Object.create`). It overrides only `invalidateQueries` (network-aware) / `forceInvalidateQueries`, and binds every other method to the real `queryClient` target (`value.bind(target)`). So private-field methods like `getQueriesData`, `setQueryData`, and `removeQueries` work correctly through it now — no need to fall back to a separate `useQueryClient`. (Confirmed when adding `queryClient.removeQueries` to `clearAllJellyseerData` in `hooks/useJellyseerr.ts`.)
|
||||
|
||||
Historical (pre-2026-06): the hook used `Object.create(queryClient)`, which broke methods relying on JavaScript private fields; back then only `invalidateQueries` was safe.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Directory, Paths } from "expo-file-system";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, ScrollView, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVPasswordEntryModal } from "@/components/login/TVPasswordEntryModal";
|
||||
import { TVPINEntryModal } from "@/components/login/TVPINEntryModal";
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
TVSettingsToggle,
|
||||
} from "@/components/tv";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { useTVUserSwitchModal } from "@/hooks/useTVUserSwitchModal";
|
||||
import { APP_LANGUAGES } from "@/i18n";
|
||||
@@ -50,7 +52,7 @@ import { clearTopShelfCacheSafely } from "@/utils/topshelf/cache";
|
||||
export default function SettingsTV() {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { settings, updateSettings, pluginSettings } = useSettings();
|
||||
const { logout, loginWithSavedCredential, loginWithPassword } = useJellyfin();
|
||||
const [user] = useAtom(userAtom);
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -59,6 +61,51 @@ export default function SettingsTV() {
|
||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||
const typography = useScaledTVTypography();
|
||||
const queryClient = useQueryClient();
|
||||
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
|
||||
useJellyseerr();
|
||||
|
||||
// Jellyseerr connection state
|
||||
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
|
||||
settings.jellyseerrServerUrl || "",
|
||||
);
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
|
||||
|
||||
const isJellyseerrLocked =
|
||||
pluginSettings?.jellyseerrServerUrl?.locked === true;
|
||||
const isJellyseerrConnected = !!jellyseerrApi;
|
||||
|
||||
const handleJellyseerrUrlBlur = useCallback(() => {
|
||||
const url = jellyseerrServerUrl.trim();
|
||||
updateSettings({ jellyseerrServerUrl: url || undefined });
|
||||
}, [jellyseerrServerUrl, updateSettings]);
|
||||
|
||||
const jellyseerrLoginMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const url = jellyseerrServerUrl.trim();
|
||||
if (!url) throw new Error("Missing server url");
|
||||
if (!user?.Name) throw new Error("Missing user info");
|
||||
const tempApi = new JellyseerrApi(url);
|
||||
const testResult = await tempApi.test();
|
||||
if (!testResult.isValid) throw new Error("Invalid server url");
|
||||
return tempApi.login(user.Name, jellyseerrPassword);
|
||||
},
|
||||
onSuccess: (loggedInUser) => {
|
||||
setJellyseerrUser(loggedInUser);
|
||||
updateSettings({ jellyseerrServerUrl: jellyseerrServerUrl.trim() });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("jellyseerr.failed_to_login"));
|
||||
},
|
||||
onSettled: () => {
|
||||
setJellyseerrPassword("");
|
||||
},
|
||||
});
|
||||
|
||||
const handleDisconnectJellyseerr = useCallback(() => {
|
||||
clearAllJellyseerData();
|
||||
setJellyseerrServerUrl("");
|
||||
setJellyseerrPassword("");
|
||||
}, [clearAllJellyseerData]);
|
||||
|
||||
// Local state for OpenSubtitles API key (only commit on blur)
|
||||
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||
@@ -877,6 +924,72 @@ export default function SettingsTV() {
|
||||
onToggle={(value) => updateSettings({ tvThemeMusicEnabled: value })}
|
||||
/>
|
||||
|
||||
{/* seerr Section */}
|
||||
<TVSectionHeader title='seerr' />
|
||||
<Text
|
||||
style={{
|
||||
color: "#9CA3AF",
|
||||
fontSize: typography.callout - 2,
|
||||
marginBottom: 16,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
{t("home.settings.plugins.jellyseerr.server_url_hint")}
|
||||
</Text>
|
||||
<TVSettingsTextInput
|
||||
label={t("home.settings.plugins.jellyseerr.server_url")}
|
||||
value={jellyseerrServerUrl}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.server_url_placeholder",
|
||||
)}
|
||||
onChangeText={setJellyseerrServerUrl}
|
||||
onBlur={handleJellyseerrUrlBlur}
|
||||
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
|
||||
/>
|
||||
{!isJellyseerrConnected && !isJellyseerrLocked && (
|
||||
<>
|
||||
<TVSettingsTextInput
|
||||
label={t("home.settings.plugins.jellyseerr.password")}
|
||||
value={jellyseerrPassword}
|
||||
placeholder={t(
|
||||
"home.settings.plugins.jellyseerr.password_placeholder",
|
||||
{ username: user?.Name },
|
||||
)}
|
||||
onChangeText={setJellyseerrPassword}
|
||||
secureTextEntry
|
||||
disabled={jellyseerrLoginMutation.isPending}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={
|
||||
jellyseerrLoginMutation.isPending
|
||||
? t("common.connecting")
|
||||
: t("common.connect")
|
||||
}
|
||||
value=''
|
||||
onPress={() => jellyseerrLoginMutation.mutate()}
|
||||
disabled={jellyseerrLoginMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TVSettingsRow
|
||||
label={
|
||||
isJellyseerrConnected
|
||||
? t("common.connected")
|
||||
: t("common.not_connected")
|
||||
}
|
||||
value=''
|
||||
showChevron={false}
|
||||
/>
|
||||
{isJellyseerrConnected && !isJellyseerrLocked && (
|
||||
<TVSettingsOptionButton
|
||||
label={t(
|
||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
||||
)}
|
||||
value=''
|
||||
onPress={handleDisconnectJellyseerr}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Storage Section */}
|
||||
<TVSectionHeader title={t("home.settings.storage.storage_title")} />
|
||||
<TVSettingsOptionButton
|
||||
|
||||
@@ -7,7 +7,12 @@ import { useAsyncDebouncer } from "@tanstack/react-pacer";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation, useSegments } from "expo-router";
|
||||
import {
|
||||
useIsFocused,
|
||||
useLocalSearchParams,
|
||||
useNavigation,
|
||||
useSegments,
|
||||
} from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { orderBy, uniqBy } from "lodash";
|
||||
import {
|
||||
@@ -20,7 +25,13 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import { Text } from "@/components/common/Text";
|
||||
@@ -41,7 +52,10 @@ import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||
import { SearchTabButtons } from "@/components/search/SearchTabButtons";
|
||||
import { TVSearchPage } from "@/components/search/TVSearchPage";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
useJellyseerr,
|
||||
validateJellyseerrSession,
|
||||
} from "@/hooks/useJellyseerr";
|
||||
import { useTVItemActionModal } from "@/hooks/useTVItemActionModal";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
@@ -106,8 +120,40 @@ export default function SearchPage() {
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const isFocused = useIsFocused();
|
||||
const { settings } = useSettings();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
|
||||
// Prompt the user to connect when a Jellyseerr server is configured but no
|
||||
// session exists yet (only once per focus, and only while the tab is focused).
|
||||
const jellyseerrAlertedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
|
||||
if (jellyseerrAlertedRef.current) return;
|
||||
jellyseerrAlertedRef.current = true;
|
||||
Alert.alert(
|
||||
t("jellyseerr.connect_to_jellyseerr"),
|
||||
t("jellyseerr.connect_in_settings"),
|
||||
);
|
||||
}, [isFocused, settings?.jellyseerrServerUrl, jellyseerrApi, t]);
|
||||
|
||||
// Validate the Jellyseerr session when switching to Discover; warn if expired.
|
||||
useEffect(() => {
|
||||
if (
|
||||
searchType !== "Discover" ||
|
||||
!jellyseerrApi ||
|
||||
!settings?.jellyseerrServerUrl
|
||||
)
|
||||
return;
|
||||
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
|
||||
if (status.valid) return;
|
||||
Alert.alert(
|
||||
t("jellyseerr.session_expired"),
|
||||
t("jellyseerr.session_expired_connect_again"),
|
||||
);
|
||||
});
|
||||
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
|
||||
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||
useState<JellyseerrSearchSort>(
|
||||
JellyseerrSearchSort[
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVOptionCard } from "@/components/tv";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import {
|
||||
useScaledTVTypography,
|
||||
useTVRelativeScale,
|
||||
} from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
||||
import { tvOptionModalAtom } from "@/utils/atoms/tvOptionModal";
|
||||
@@ -22,6 +25,7 @@ export default function TVOptionModal() {
|
||||
const router = useRouter();
|
||||
const modalState = useAtomValue(tvOptionModalAtom);
|
||||
const typography = useScaledTVTypography();
|
||||
const relativeScale = useTVRelativeScale();
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const firstCardRef = useRef<View>(null);
|
||||
@@ -97,8 +101,15 @@ export default function TVOptionModal() {
|
||||
}
|
||||
|
||||
const { title, options } = modalState;
|
||||
const scaledCardWidth = scaleSize(160);
|
||||
const scaledCardHeight = scaleSize(75);
|
||||
// Honor the caller-provided card size (e.g. wider cards for long root-folder
|
||||
// paths) and grow it in step with the user's text-scale setting so larger
|
||||
// fonts don't get clipped.
|
||||
const scaledCardWidth = scaleSize(
|
||||
(modalState.cardWidth ?? 160) * relativeScale,
|
||||
);
|
||||
const scaledCardHeight = scaleSize(
|
||||
(modalState.cardHeight ?? 75) * relativeScale,
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { TVRequestOptionRow } from "@/components/jellyseerr/tv/TVRequestOptionRow";
|
||||
import { TVToggleOptionRow } from "@/components/jellyseerr/tv/TVToggleOptionRow";
|
||||
import { TVButton, TVOptionSelector } from "@/components/tv";
|
||||
import { TVButton } from "@/components/tv";
|
||||
import type { TVOptionItem } from "@/components/tv/TVOptionSelector";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
|
||||
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||
import type {
|
||||
QualityProfile,
|
||||
@@ -35,6 +36,7 @@ export default function TVRequestModalPage() {
|
||||
const modalState = useAtomValue(tvRequestModalAtom);
|
||||
const { t } = useTranslation();
|
||||
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
|
||||
const { showOptions } = useTVOptionModal();
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [requestOverrides, setRequestOverrides] = useState<MediaRequestBody>({
|
||||
@@ -43,10 +45,6 @@ export default function TVRequestModalPage() {
|
||||
userId: jellyseerrUser?.id,
|
||||
});
|
||||
|
||||
const [activeSelector, setActiveSelector] = useState<
|
||||
"profile" | "folder" | "user" | null
|
||||
>(null);
|
||||
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const sheetTranslateY = useRef(new Animated.Value(200)).current;
|
||||
|
||||
@@ -242,17 +240,14 @@ export default function TVRequestModalPage() {
|
||||
// Handlers
|
||||
const handleProfileChange = useCallback((profileId: number) => {
|
||||
setRequestOverrides((prev) => ({ ...prev, profileId }));
|
||||
setActiveSelector(null);
|
||||
}, []);
|
||||
|
||||
const handleFolderChange = useCallback((rootFolder: string) => {
|
||||
setRequestOverrides((prev) => ({ ...prev, rootFolder }));
|
||||
setActiveSelector(null);
|
||||
}, []);
|
||||
|
||||
const handleUserChange = useCallback((userId: number) => {
|
||||
setRequestOverrides((prev) => ({ ...prev, userId }));
|
||||
setActiveSelector(null);
|
||||
}, []);
|
||||
|
||||
const handleTagToggle = useCallback(
|
||||
@@ -353,18 +348,37 @@ export default function TVRequestModalPage() {
|
||||
<TVRequestOptionRow
|
||||
label={t("jellyseerr.quality_profile")}
|
||||
value={selectedProfileName}
|
||||
onPress={() => setActiveSelector("profile")}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("jellyseerr.quality_profile"),
|
||||
options: qualityProfileOptions,
|
||||
onSelect: handleProfileChange,
|
||||
})
|
||||
}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
<TVRequestOptionRow
|
||||
label={t("jellyseerr.root_folder")}
|
||||
value={selectedFolderName}
|
||||
onPress={() => setActiveSelector("folder")}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("jellyseerr.root_folder"),
|
||||
options: rootFolderOptions,
|
||||
onSelect: handleFolderChange,
|
||||
cardWidth: 280,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TVRequestOptionRow
|
||||
label={t("jellyseerr.request_as")}
|
||||
value={selectedUserName}
|
||||
onPress={() => setActiveSelector("user")}
|
||||
onPress={() =>
|
||||
showOptions({
|
||||
title: t("jellyseerr.request_as"),
|
||||
options: userOptions,
|
||||
onSelect: handleUserChange,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{tagItems.length > 0 && (
|
||||
@@ -409,33 +423,6 @@ export default function TVRequestModalPage() {
|
||||
</TVFocusGuideView>
|
||||
</BlurView>
|
||||
</Animated.View>
|
||||
|
||||
{/* Sub-selectors */}
|
||||
<TVOptionSelector
|
||||
visible={activeSelector === "profile"}
|
||||
title={t("jellyseerr.quality_profile")}
|
||||
options={qualityProfileOptions}
|
||||
onSelect={handleProfileChange}
|
||||
onClose={() => setActiveSelector(null)}
|
||||
cancelLabel={t("jellyseerr.cancel")}
|
||||
/>
|
||||
<TVOptionSelector
|
||||
visible={activeSelector === "folder"}
|
||||
title={t("jellyseerr.root_folder")}
|
||||
options={rootFolderOptions}
|
||||
onSelect={handleFolderChange}
|
||||
onClose={() => setActiveSelector(null)}
|
||||
cancelLabel={t("jellyseerr.cancel")}
|
||||
cardWidth={280}
|
||||
/>
|
||||
<TVOptionSelector
|
||||
visible={activeSelector === "user"}
|
||||
title={t("jellyseerr.request_as")}
|
||||
options={userOptions}
|
||||
onSelect={handleUserChange}
|
||||
onClose={() => setActiveSelector(null)}
|
||||
cancelLabel={t("jellyseerr.cancel")}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
MediaType,
|
||||
} from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import { scaleSize } from "@/utils/scaleSize";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface TVSeasonToggleCardProps {
|
||||
@@ -49,6 +50,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
||||
hasTVPreferredFocus,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.08 });
|
||||
|
||||
@@ -119,7 +121,10 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
||||
<Text
|
||||
style={[
|
||||
styles.seasonTitle,
|
||||
{ color: focused ? "#000000" : "#FFFFFF" },
|
||||
{
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#000000" : "#FFFFFF",
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -132,6 +137,7 @@ const TVSeasonToggleCard: React.FC<TVSeasonToggleCardProps> = ({
|
||||
style={[
|
||||
styles.episodeCount,
|
||||
{
|
||||
fontSize: typography.callout - 4,
|
||||
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)",
|
||||
},
|
||||
]}
|
||||
@@ -251,14 +257,15 @@ export default function TVSeasonSelectModalPage() {
|
||||
};
|
||||
|
||||
if (modalState.hasAdvancedRequestPermission) {
|
||||
// Close this modal and open the advanced request modal
|
||||
router.back();
|
||||
// Replace this sheet with the advanced request modal so it takes our
|
||||
// place in the stack instead of stacking on top (which breaks focus).
|
||||
showRequestModal({
|
||||
requestBody: body,
|
||||
title: modalState.title,
|
||||
id: modalState.mediaId,
|
||||
mediaType: MediaType.TV,
|
||||
onRequested: modalState.onRequested,
|
||||
replace: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -401,7 +408,7 @@ const styles = StyleSheet.create({
|
||||
gap: 16,
|
||||
},
|
||||
seasonCard: {
|
||||
width: 160,
|
||||
width: scaleSize(220),
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
@@ -415,7 +422,10 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 8,
|
||||
},
|
||||
seasonInfo: {
|
||||
flex: 1,
|
||||
// Note: no `flex: 1` here — the card is an auto-height column, so flex:1
|
||||
// (flexBasis: 0) would collapse this box and hide the text. Let it size to
|
||||
// its content instead.
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
seasonTitle: {
|
||||
fontWeight: "600",
|
||||
@@ -426,9 +436,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
episodeCount: {
|
||||
fontSize: 14,
|
||||
},
|
||||
episodeCount: {},
|
||||
statusBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
@@ -166,6 +167,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
isFirstSlide = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const { t } = useTranslation();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
@@ -238,7 +240,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginLeft: sizes.padding.horizontal,
|
||||
}}
|
||||
>
|
||||
{slideTitle}
|
||||
@@ -249,7 +251,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingHorizontal: sizes.padding.horizontal,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Animated, FlatList, Pressable, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useTVFocusAnimation } from "@/components/tv/hooks/useTVFocusAnimation";
|
||||
import { useScaledTVSizes } from "@/constants/TVSizes";
|
||||
import { useScaledTVTypography } from "@/constants/TVTypography";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
|
||||
@@ -233,6 +234,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -243,7 +245,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginLeft: sizes.padding.horizontal,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -254,7 +256,7 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingHorizontal: sizes.padding.horizontal,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
@@ -285,6 +287,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -295,7 +298,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginLeft: sizes.padding.horizontal,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -306,7 +309,7 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingHorizontal: sizes.padding.horizontal,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
@@ -337,6 +340,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -347,7 +351,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginLeft: sizes.padding.horizontal,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -358,7 +362,7 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingHorizontal: sizes.padding.horizontal,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
}}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { TVJellyseerrSearchResults } from "./TVJellyseerrSearchResults";
|
||||
import { TVSearchSection } from "./TVSearchSection";
|
||||
import { TVSearchTabBadges } from "./TVSearchTabBadges";
|
||||
|
||||
const HORIZONTAL_PADDING = 60;
|
||||
const TOP_PADDING = 100;
|
||||
// Height of the native search bar itself. The tvOS grid keyboard presents as
|
||||
// its own overlay when the field is focused, so we only reserve the bar height
|
||||
@@ -163,6 +162,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
discoverSliders,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -251,7 +251,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
marginHorizontal: HORIZONTAL_PADDING,
|
||||
marginHorizontal: sizes.padding.horizontal,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
@@ -280,12 +280,15 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode='on-drag'
|
||||
contentContainerStyle={{
|
||||
// Top padding so the focus-scale/shadow on the first row (filter
|
||||
// badges) isn't clipped against the ScrollView's top edge.
|
||||
paddingTop: 16,
|
||||
paddingBottom: insets.bottom + 60,
|
||||
}}
|
||||
>
|
||||
{/* Search Type Tab Badges */}
|
||||
{showDiscover && (
|
||||
<View style={{ marginHorizontal: HORIZONTAL_PADDING }}>
|
||||
<View style={{ marginHorizontal: sizes.padding.horizontal }}>
|
||||
<TVSearchTabBadges
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
|
||||
@@ -55,6 +55,20 @@ export type ScaledTVTypography = {
|
||||
callout: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the user's text-scale factor relative to the Default scale (1.0 at
|
||||
* Default, >1 for Large/ExtraLarge, <1 for Small). Use it to scale containers
|
||||
* (e.g. option-card width/height) in step with the scaled font so larger text
|
||||
* settings don't overflow fixed boxes.
|
||||
*/
|
||||
export const useTVRelativeScale = (): number => {
|
||||
const { settings } = useSettings();
|
||||
const scale =
|
||||
scaleMultipliers[settings.tvTypographyScale] ??
|
||||
scaleMultipliers[TVTypographyScale.Default];
|
||||
return scale / scaleMultipliers[TVTypographyScale.Default];
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that returns scaled TV typography values based on user settings.
|
||||
* Use this instead of the static TVTypography constant for dynamic scaling.
|
||||
|
||||
@@ -70,6 +70,35 @@ export const clearJellyseerrStorageData = () => {
|
||||
storage.remove(JELLYSEERR_COOKIES);
|
||||
};
|
||||
|
||||
export type JellyseerrSessionStatus =
|
||||
| { valid: true }
|
||||
| { valid: false; reason: "no_session" | "expired" };
|
||||
|
||||
/**
|
||||
* Checks whether the persisted Jellyseerr session (user + cookies) is still
|
||||
* valid by hitting the server status endpoint. Clears local session data if the
|
||||
* request fails (expired/revoked cookie).
|
||||
*/
|
||||
export async function validateJellyseerrSession(
|
||||
serverUrl: string,
|
||||
): Promise<JellyseerrSessionStatus> {
|
||||
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
|
||||
if (!user || !cookies) {
|
||||
return { valid: false, reason: "no_session" };
|
||||
}
|
||||
|
||||
try {
|
||||
const api = new JellyseerrApi(serverUrl);
|
||||
await api.axios.get(Endpoints.API_V1 + Endpoints.STATUS);
|
||||
return { valid: true };
|
||||
} catch {
|
||||
clearJellyseerrStorageData();
|
||||
return { valid: false, reason: "expired" };
|
||||
}
|
||||
}
|
||||
|
||||
export enum Endpoints {
|
||||
STATUS = "/status",
|
||||
API_V1 = "/api/v1",
|
||||
@@ -450,7 +479,8 @@ export const useJellyseerr = () => {
|
||||
clearJellyseerrStorageData();
|
||||
setJellyseerrUser(undefined);
|
||||
updateSettings({ jellyseerrServerUrl: undefined });
|
||||
}, []);
|
||||
queryClient.removeQueries({ queryKey: ["search", "jellyseerr"] });
|
||||
}, [queryClient]);
|
||||
|
||||
const requestMedia = useCallback(
|
||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||
|
||||
@@ -11,6 +11,12 @@ interface ShowRequestModalParams {
|
||||
id: number;
|
||||
mediaType: MediaType;
|
||||
onRequested: () => void;
|
||||
/**
|
||||
* Replace the current route instead of pushing. Use when opening the request
|
||||
* modal from another modal (e.g. the season selector) so the new sheet takes
|
||||
* its place rather than stacking on top of it (which breaks TV focus).
|
||||
*/
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
export const useTVRequestModal = () => {
|
||||
@@ -25,7 +31,11 @@ export const useTVRequestModal = () => {
|
||||
mediaType: params.mediaType,
|
||||
onRequested: params.onRequested,
|
||||
});
|
||||
router.push("/(auth)/tv-request-modal");
|
||||
if (params.replace) {
|
||||
router.replace("/(auth)/tv-request-modal");
|
||||
} else {
|
||||
router.push("/(auth)/tv-request-modal");
|
||||
}
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
@@ -505,7 +505,11 @@
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
"seeAll": "See all",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting…",
|
||||
"connected": "Connected",
|
||||
"not_connected": "Not connected"
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
@@ -732,6 +736,10 @@
|
||||
"request_button": "Request",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Are you sure you want to request all seasons?",
|
||||
"failed_to_login": "Failed to log in",
|
||||
"connect_to_jellyseerr": "Connect to Jellyseerr",
|
||||
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
|
||||
"session_expired": "Session expired",
|
||||
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
|
||||
"cast": "Cast",
|
||||
"details": "Details",
|
||||
"status": "Status",
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
"hide_volume_slider": "Hide Volume Slider",
|
||||
"hide_volume_slider_description": "Nascondi il cursore del volume nel lettore video",
|
||||
"hide_brightness_slider": "Hide Brightness Slider",
|
||||
"hide_brightness_slider_description": "Nascondi il cursore della luminosità nel lettore video"
|
||||
"hide_brightness_slider_description": "Hide the brightness slider in the video player"
|
||||
},
|
||||
"audio": {
|
||||
"audio_title": "Audio",
|
||||
@@ -237,10 +237,10 @@
|
||||
"language": "Lingua",
|
||||
"transcode_mode": {
|
||||
"title": "Audio Transcoding",
|
||||
"description": "Controlla come viene gestito l'audio surround (7.1, TrueHD, DTS-HD)",
|
||||
"auto": "Automatico",
|
||||
"description": "Controls how surround audio (7.1, TrueHD, DTS-HD) is handled",
|
||||
"auto": "Auto",
|
||||
"stereo": "Force Stereo",
|
||||
"5_1": "Consenti 5.1",
|
||||
"5_1": "Allow 5.1",
|
||||
"passthrough": "Passthrough"
|
||||
}
|
||||
},
|
||||
@@ -262,20 +262,20 @@
|
||||
"OnlyForced": "Solo forzati"
|
||||
},
|
||||
"opensubtitles_title": "OpenSubtitles",
|
||||
"opensubtitles_hint": "Inserisci la tua chiave API OpenSubtitles per abilitare la ricerca dei sottotitoli quando il tuo server Jellyfin non ha un provider di sottotitoli configurato.",
|
||||
"opensubtitles_hint": "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured.",
|
||||
"opensubtitles_api_key": "API Key",
|
||||
"opensubtitles_api_key_placeholder": "Inserisci la chiave API...",
|
||||
"opensubtitles_get_key": "Ottieni la tua chiave API gratuita su opensubtitles.com/en/consumers",
|
||||
"opensubtitles_api_key_placeholder": "Enter API key...",
|
||||
"opensubtitles_get_key": "Get your free API key at opensubtitles.com/en/consumers",
|
||||
"mpv_subtitle_scale": "Subtitle Scale",
|
||||
"mpv_subtitle_margin_y": "Vertical Margin",
|
||||
"mpv_subtitle_align_x": "Horizontal Align",
|
||||
"mpv_subtitle_align_y": "Vertical Align",
|
||||
"align": {
|
||||
"left": "Sinistra",
|
||||
"center": "Centro",
|
||||
"right": "Destra",
|
||||
"top": "Alto",
|
||||
"bottom": "Basso"
|
||||
"left": "Left",
|
||||
"center": "Center",
|
||||
"right": "Right",
|
||||
"top": "Top",
|
||||
"bottom": "Bottom"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
@@ -307,9 +307,9 @@
|
||||
"disabled": "Disabilitato"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musica",
|
||||
"playback_title": "Riproduzione",
|
||||
"playback_description": "Configura come viene riprodotta la musica.",
|
||||
"title": "Music",
|
||||
"playback_title": "Playback",
|
||||
"playback_description": "Configure how music is played.",
|
||||
"prefer_downloaded": "Prefer Downloaded Songs",
|
||||
"caching_title": "Caching",
|
||||
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
|
||||
@@ -333,7 +333,7 @@
|
||||
"tv_quota_days": "Giorni di quota per le serie TV",
|
||||
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
|
||||
"unlimited": "Illimitato",
|
||||
"plus_n_more": "+{{n}} altro",
|
||||
"plus_n_more": "+{{n}} more",
|
||||
"order_by": {
|
||||
"DEFAULT": "Predefinito",
|
||||
"VOTE_COUNT_AND_AVERAGE": "Conteggio delle votazioni e media",
|
||||
@@ -352,25 +352,25 @@
|
||||
}
|
||||
},
|
||||
"streamystats": {
|
||||
"disable_streamystats": "Disabilita Streamystats",
|
||||
"disable_streamystats": "Disable Streamystats",
|
||||
"enable_search": "Use for Search",
|
||||
"url": "URL",
|
||||
"server_url_placeholder": "http(s)://streamystats.example.com",
|
||||
"streamystats_search_hint": "Inserisci l'URL per il tuo server Streamystats. L'URL dovrebbe includere http o https ed eventualmente la porta.",
|
||||
"streamystats_search_hint": "Enter the URL for your Streamystats server. The URL should include http or https and optionally the port.",
|
||||
"read_more_about_streamystats": "Read More About Streamystats.",
|
||||
"save": "Salva",
|
||||
"features_title": "Funzionalità",
|
||||
"save": "Save",
|
||||
"features_title": "Features",
|
||||
"enable_movie_recommendations": "Movie Recommendations",
|
||||
"enable_series_recommendations": "Series Recommendations",
|
||||
"enable_promoted_watchlists": "Promoted Watchlists",
|
||||
"hide_watchlists_tab": "Hide Watchlists Tab",
|
||||
"home_sections_hint": "Mostra consigli personalizzati e watchlist promosse da Streamystats nella home page.",
|
||||
"home_sections_hint": "Show personalized recommendations and promoted watchlists from Streamystats on the home page.",
|
||||
"recommended_movies": "Recommended Movies",
|
||||
"recommended_series": "Recommended Series",
|
||||
"toasts": {
|
||||
"saved": "Salvato",
|
||||
"refreshed": "Impostazioni aggiornate dal server",
|
||||
"disabled": "Streamystats disabilitato"
|
||||
"saved": "Saved",
|
||||
"refreshed": "Settings refreshed from server",
|
||||
"disabled": "Streamystats disabled"
|
||||
},
|
||||
"refresh_from_server": "Refresh Settings from Server"
|
||||
},
|
||||
@@ -385,17 +385,17 @@
|
||||
"size_used": "{{used}} di {{total}} usato",
|
||||
"delete_all_downloaded_files": "Cancella Tutti i File Scaricati",
|
||||
"music_cache_title": "Music Cache",
|
||||
"music_cache_description": "Precarica automaticamente i brani mentre ascolti per una riproduzione più fluida e il supporto offline",
|
||||
"music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support",
|
||||
"clear_music_cache": "Clear Music Cache",
|
||||
"music_cache_size": "{{size}} nella cache",
|
||||
"music_cache_cleared": "Cache musicale cancellata",
|
||||
"music_cache_size": "{{size}} cached",
|
||||
"music_cache_cleared": "Music cache cleared",
|
||||
"delete_all_downloaded_songs": "Delete All Downloaded Songs",
|
||||
"downloaded_songs_size": "{{size}} scaricato",
|
||||
"downloaded_songs_deleted": "Brani scaricati eliminati",
|
||||
"downloaded_songs_size": "{{size}} downloaded",
|
||||
"downloaded_songs_deleted": "Downloaded songs deleted",
|
||||
"clear_all_cache": "Clear All Cache",
|
||||
"clear_all_cache_confirm": "Clear All Cache?",
|
||||
"clear_all_cache_confirm_desc": "Sei sicuro di voler cancellare tutti i dati nella cache? Questo cancellerà tutte le immagini nella cache, i file musicali, i sottotitoli e le cache delle interrogazioni. Le impostazioni e la sessione di login verranno mantenute.",
|
||||
"clear_all_cache_error_desc": "Si è verificato un errore durante la cancellazione della cache."
|
||||
"clear_all_cache_confirm_desc": "Are you sure you want to clear all cached data? This will clear all cached images, music files, subtitles, and query caches. Your settings and login session will be kept.",
|
||||
"clear_all_cache_error_desc": "An error occurred while clearing the cache."
|
||||
},
|
||||
"intro": {
|
||||
"title": "Intro",
|
||||
@@ -404,8 +404,8 @@
|
||||
},
|
||||
"logs": {
|
||||
"logs_title": "Log",
|
||||
"export_logs": "Esporta i logs",
|
||||
"click_for_more_info": "Clicca per maggiori informazioni",
|
||||
"export_logs": "Export logs",
|
||||
"click_for_more_info": "Click for more info",
|
||||
"level": "Livello",
|
||||
"no_logs_available": "Nessun log disponibile",
|
||||
"delete_all_logs": "Cancella tutti i log"
|
||||
@@ -419,17 +419,17 @@
|
||||
"error_deleting_files": "Errore nella cancellazione dei file"
|
||||
},
|
||||
"security": {
|
||||
"title": "Sicurezza",
|
||||
"title": "Security",
|
||||
"inactivity_timeout": {
|
||||
"title": "Inactivity Timeout",
|
||||
"disabled": "Disabilitato",
|
||||
"1_minute": "1 minuto",
|
||||
"5_minutes": "5 minuti",
|
||||
"15_minutes": "15 minuti",
|
||||
"30_minutes": "30 minuti",
|
||||
"1_hour": "1 ora",
|
||||
"4_hours": "4 ore",
|
||||
"24_hours": "24 ore"
|
||||
"disabled": "Disabled",
|
||||
"1_minute": "1 minute",
|
||||
"5_minutes": "5 minutes",
|
||||
"15_minutes": "15 minutes",
|
||||
"30_minutes": "30 minutes",
|
||||
"1_hour": "1 hour",
|
||||
"4_hours": "4 hours",
|
||||
"24_hours": "24 hours"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -494,18 +494,18 @@
|
||||
"mark_as_not_played": "Mark as not Played",
|
||||
"none": "Nulla",
|
||||
"track": "Traccia",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Cancella",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"remove": "Rimuovi",
|
||||
"back": "Indietro",
|
||||
"continue": "Continua",
|
||||
"verifying": "Verifica in corso...",
|
||||
"login": "Accedi",
|
||||
"episodes": "Episodi",
|
||||
"movies": "Film",
|
||||
"loading": "Caricamento…",
|
||||
"seeAll": "Visualizza tutti"
|
||||
"remove": "Remove",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
},
|
||||
"search": {
|
||||
"search": "Cerca...",
|
||||
@@ -519,10 +519,10 @@
|
||||
"episodes": "Episodi",
|
||||
"collections": "Collezioni",
|
||||
"actors": "Attori",
|
||||
"artists": "Artisti",
|
||||
"albums": "Album",
|
||||
"songs": "Tracce",
|
||||
"playlists": "Playlist",
|
||||
"artists": "Artists",
|
||||
"albums": "Albums",
|
||||
"songs": "Songs",
|
||||
"playlists": "Playlists",
|
||||
"request_movies": "Film Richiesti",
|
||||
"request_series": "Serie Richieste",
|
||||
"recently_added": "Aggiunti di Recente",
|
||||
@@ -554,7 +554,7 @@
|
||||
"movies": "film",
|
||||
"series": "serie TV",
|
||||
"boxsets": "cofanetti",
|
||||
"playlists": "Playlist",
|
||||
"playlists": "Playlists",
|
||||
"items": "elementi"
|
||||
},
|
||||
"options": {
|
||||
@@ -566,7 +566,7 @@
|
||||
"cover": "Copertina",
|
||||
"show_titles": "Mostra titoli",
|
||||
"show_stats": "Mostra statistiche",
|
||||
"options_title": "Impostazioni"
|
||||
"options_title": "Options"
|
||||
},
|
||||
"filters": {
|
||||
"genres": "Generi",
|
||||
@@ -575,10 +575,10 @@
|
||||
"filter_by": "Filter By",
|
||||
"sort_order": "Criterio di ordinamento",
|
||||
"tags": "Tag",
|
||||
"all": "Tutto",
|
||||
"reset": "Ripristina",
|
||||
"asc": "Crescente",
|
||||
"desc": "Decrescente"
|
||||
"all": "All",
|
||||
"reset": "Reset",
|
||||
"asc": "Ascending",
|
||||
"desc": "Descending"
|
||||
}
|
||||
},
|
||||
"favorites": {
|
||||
@@ -595,7 +595,7 @@
|
||||
"no_links": "Nessun link"
|
||||
},
|
||||
"player": {
|
||||
"live": "IN DIRETTA",
|
||||
"live": "LIVE",
|
||||
"mpv_player_title": "MPV Player",
|
||||
"error": "Errore",
|
||||
"failed_to_get_stream_url": "Impossibile ottenere l'URL dello stream",
|
||||
@@ -606,40 +606,40 @@
|
||||
"next_episode": "Prossimo Episodio",
|
||||
"continue_watching": "Continua a guardare",
|
||||
"go_back": "Indietro",
|
||||
"downloaded_file_title": "Questo file è stato scaricato",
|
||||
"downloaded_file_message": "Vuoi riprodurre il file scaricato?",
|
||||
"downloaded_file_yes": "Si",
|
||||
"downloaded_file_title": "You have this file downloaded",
|
||||
"downloaded_file_message": "Do you want to play the downloaded file?",
|
||||
"downloaded_file_yes": "Yes",
|
||||
"downloaded_file_no": "No",
|
||||
"downloaded_file_cancel": "Annulla",
|
||||
"swipe_down_settings": "Scorri in basso per le impostazioni",
|
||||
"ends_at": "Termina alle {{time}}",
|
||||
"downloaded_file_cancel": "Cancel",
|
||||
"swipe_down_settings": "Swipe down for settings",
|
||||
"ends_at": "Ends at {{time}}",
|
||||
"search_subtitles": "Search Subtitles",
|
||||
"subtitle_tracks": "Tracce",
|
||||
"subtitle_tracks": "Tracks",
|
||||
"subtitle_search": "Search & Download",
|
||||
"download": "Scarica",
|
||||
"subtitle_download_hint": "I sottotitoli scaricati verranno salvati nella tua libreria",
|
||||
"download": "Download",
|
||||
"subtitle_download_hint": "Downloaded subtitles will be saved to your library",
|
||||
"using_jellyfin_server": "Using Jellyfin Server",
|
||||
"language": "Lingua",
|
||||
"results": "Risultati",
|
||||
"searching": "Ricerca in corso...",
|
||||
"search_failed": "Ricerca fallita",
|
||||
"no_subtitle_provider": "Nessun provider di sottotitoli configurato sul server",
|
||||
"no_subtitles_found": "Nessun sottotitolo trovato",
|
||||
"add_opensubtitles_key_hint": "Aggiungi la chiave API OpenSubtitles nelle impostazioni",
|
||||
"settings": "Impostazioni",
|
||||
"language": "Language",
|
||||
"results": "Results",
|
||||
"searching": "Searching...",
|
||||
"search_failed": "Search failed",
|
||||
"no_subtitle_provider": "No subtitle provider configured on server",
|
||||
"no_subtitles_found": "No subtitles found",
|
||||
"add_opensubtitles_key_hint": "Add OpenSubtitles API key in settings for client-side fallback",
|
||||
"settings": "Settings",
|
||||
"skip_intro": "Skip Intro",
|
||||
"skip_credits": "Skip Credits",
|
||||
"stopPlayback": "Stop Playback",
|
||||
"stopPlayingTitle": "Interrompere la riproduzione \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Sei sicuro di voler interrompere la riproduzione?",
|
||||
"downloaded": "Scaricato",
|
||||
"missing_parameters": "Parametri di riproduzione mancanti"
|
||||
"stopPlayingTitle": "Stop playing \"{{title}}\"?",
|
||||
"stopPlayingConfirm": "Are you sure you want to stop playback?",
|
||||
"downloaded": "Downloaded",
|
||||
"missing_parameters": "Missing playback parameters"
|
||||
},
|
||||
"chapters": {
|
||||
"title": "Capitoli",
|
||||
"chapter_number": "Capitolo {{number}}",
|
||||
"open": "Apri capitoli",
|
||||
"close": "Chiudi i capitoli"
|
||||
"title": "Chapters",
|
||||
"chapter_number": "Chapter {{number}}",
|
||||
"open": "Open chapters",
|
||||
"close": "Close chapters"
|
||||
},
|
||||
"item_card": {
|
||||
"next_up": "Il prossimo",
|
||||
@@ -664,19 +664,19 @@
|
||||
"quality": "Qualità",
|
||||
"audio": "Audio",
|
||||
"subtitles": {
|
||||
"label": "Sottotitoli",
|
||||
"none": "Vuoto",
|
||||
"tracks": "Tracce"
|
||||
"label": "Subtitle",
|
||||
"none": "None",
|
||||
"tracks": "Tracks"
|
||||
},
|
||||
"show_more": "Mostra di più",
|
||||
"show_less": "Mostra di meno",
|
||||
"left": "sinistra",
|
||||
"director": "Regista",
|
||||
"left": "left",
|
||||
"director": "Director",
|
||||
"cast": "Cast",
|
||||
"technical_details": "Technical Details",
|
||||
"appeared_in": "Apparso in",
|
||||
"movies": "Film",
|
||||
"shows": "Serie",
|
||||
"movies": "Movies",
|
||||
"shows": "Shows",
|
||||
"could_not_load_item": "Impossibile caricare l'elemento",
|
||||
"none": "Nessuno",
|
||||
"download": {
|
||||
@@ -691,10 +691,10 @@
|
||||
"mark_played": "Mark as Watched",
|
||||
"mark_unplayed": "Mark as Unwatched",
|
||||
"resume_playback": "Resume Playback",
|
||||
"resume_playback_description": "Vuoi continuare da dove hai lasciato o riniziare da capo?",
|
||||
"resume_playback_description": "Do you want to continue where you left off or start from the beginning?",
|
||||
"play_from_start": "Play from Start",
|
||||
"continue_from": "Continua da {{time}}",
|
||||
"no_data_available": "Nessun dato disponibile"
|
||||
"continue_from": "Continue from {{time}}",
|
||||
"no_data_available": "No data available"
|
||||
},
|
||||
"live_tv": {
|
||||
"next": "Prossimo",
|
||||
@@ -706,16 +706,16 @@
|
||||
"sports": "Sport",
|
||||
"for_kids": "Per Bambini",
|
||||
"news": "Notiziari",
|
||||
"page_of": "Pagina {{current}} di {{total}}",
|
||||
"no_programs": "Nessun programma disponibile",
|
||||
"no_channels": "Nessun canale disponibile",
|
||||
"page_of": "Page {{current}} of {{total}}",
|
||||
"no_programs": "No programs available",
|
||||
"no_channels": "No channels available",
|
||||
"tabs": {
|
||||
"programs": "Programmi",
|
||||
"guide": "Guida",
|
||||
"channels": "Canali",
|
||||
"recordings": "Registrazioni",
|
||||
"schedule": "Pianifica",
|
||||
"series": "Serie Tv"
|
||||
"programs": "Programs",
|
||||
"guide": "Guide",
|
||||
"channels": "Channels",
|
||||
"recordings": "Recordings",
|
||||
"schedule": "Schedule",
|
||||
"series": "Series"
|
||||
}
|
||||
},
|
||||
"jellyseerr": {
|
||||
@@ -761,12 +761,12 @@
|
||||
"decline": "Rifiuta",
|
||||
"requested_by": "Richiesto da {{user}}",
|
||||
"unknown_user": "Utente Sconosciuto",
|
||||
"select": "Seleziona",
|
||||
"select": "Select",
|
||||
"request_all": "Request All",
|
||||
"request_seasons": "Request Seasons",
|
||||
"select_seasons": "Select Seasons",
|
||||
"request_selected": "Request Selected",
|
||||
"n_selected": "{{count}} selezionati",
|
||||
"n_selected": "{{count}} selected",
|
||||
"toasts": {
|
||||
"jellyseer_does_not_meet_requirements": "Il server Jellyseerr non soddisfa i requisiti minimi di versione! Aggiornare almeno alla versione 2.0.0.",
|
||||
"jellyseerr_test_failed": "Il test di Jellyseerr non è riuscito. Riprovare.",
|
||||
@@ -787,39 +787,39 @@
|
||||
"library": "Libreria",
|
||||
"custom_links": "Collegamenti personalizzati",
|
||||
"favorites": "Preferiti",
|
||||
"settings": "Impostazioni"
|
||||
"settings": "Settings"
|
||||
},
|
||||
"music": {
|
||||
"title": "Musica",
|
||||
"title": "Music",
|
||||
"tabs": {
|
||||
"suggestions": "Suggerimenti",
|
||||
"albums": "Album",
|
||||
"artists": "Artisti",
|
||||
"playlists": "Playlist",
|
||||
"suggestions": "Suggestions",
|
||||
"albums": "Albums",
|
||||
"artists": "Artists",
|
||||
"playlists": "Playlists",
|
||||
"tracks": "tracks"
|
||||
},
|
||||
"recently_added": "Recently Added",
|
||||
"recently_played": "Recently Played",
|
||||
"frequently_played": "Frequently Played",
|
||||
"top_tracks": "Top Tracks",
|
||||
"play": "Riproduci",
|
||||
"shuffle": "Riproduzione casuale",
|
||||
"play": "Play",
|
||||
"shuffle": "Shuffle",
|
||||
"play_top_tracks": "Play Top Tracks",
|
||||
"no_suggestions": "Nessun suggerimento disponibile",
|
||||
"no_albums": "Nessun album trovato",
|
||||
"no_artists": "Artista non trovato",
|
||||
"no_playlists": "Nessuna playlist trovata",
|
||||
"album_not_found": "Album non trovato",
|
||||
"artist_not_found": "Artista non trovato",
|
||||
"playlist_not_found": "Playlist non trovata",
|
||||
"no_suggestions": "No suggestions available",
|
||||
"no_albums": "No albums found",
|
||||
"no_artists": "No artists found",
|
||||
"no_playlists": "No playlists found",
|
||||
"album_not_found": "Album not found",
|
||||
"artist_not_found": "Artist not found",
|
||||
"playlist_not_found": "Playlist not found",
|
||||
"track_options": {
|
||||
"play_next": "Play Next",
|
||||
"add_to_queue": "Add to Queue",
|
||||
"add_to_playlist": "Add to Playlist",
|
||||
"download": "Scarica",
|
||||
"downloaded": "Scaricato",
|
||||
"downloading": "Scaricamento...",
|
||||
"cached": "Memorizzato nella cache",
|
||||
"download": "Download",
|
||||
"downloaded": "Downloaded",
|
||||
"downloading": "Downloading...",
|
||||
"cached": "Cached",
|
||||
"delete_download": "Delete Download",
|
||||
"delete_cache": "Remove from Cache",
|
||||
"go_to_artist": "Go to Artist",
|
||||
@@ -831,112 +831,112 @@
|
||||
"playlists": {
|
||||
"create_playlist": "Create Playlist",
|
||||
"playlist_name": "Playlist Name",
|
||||
"enter_name": "Inserisci il nome della playlist",
|
||||
"create": "Crea",
|
||||
"search_playlists": "Cerca playlist...",
|
||||
"added_to": "Aggiunto a {{name}}",
|
||||
"added": "Aggiunto alla playlist",
|
||||
"removed_from": "Rimosso da {{name}}",
|
||||
"removed": "Rimosso dalla playlist",
|
||||
"created": "Playlist creata",
|
||||
"enter_name": "Enter playlist name",
|
||||
"create": "Create",
|
||||
"search_playlists": "Search playlists...",
|
||||
"added_to": "Added to {{name}}",
|
||||
"added": "Added to playlist",
|
||||
"removed_from": "Removed from {{name}}",
|
||||
"removed": "Removed from playlist",
|
||||
"created": "Playlist created",
|
||||
"create_new": "Create New Playlist",
|
||||
"failed_to_add": "Impossibile aggiungere alla playlist",
|
||||
"failed_to_remove": "Impossibile rimuovere dalla playlist",
|
||||
"failed_to_create": "Impossibile creare la playlist",
|
||||
"failed_to_add": "Failed to add to playlist",
|
||||
"failed_to_remove": "Failed to remove from playlist",
|
||||
"failed_to_create": "Failed to create playlist",
|
||||
"delete_playlist": "Delete Playlist",
|
||||
"delete_confirm": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
||||
"deleted": "Playlist eliminata",
|
||||
"failed_to_delete": "Impossibile eliminare la playlist"
|
||||
"delete_confirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleted": "Playlist deleted",
|
||||
"failed_to_delete": "Failed to delete playlist"
|
||||
},
|
||||
"sort": {
|
||||
"title": "Sort By",
|
||||
"alphabetical": "Alfabetico",
|
||||
"alphabetical": "Alphabetical",
|
||||
"date_created": "Date Created"
|
||||
}
|
||||
},
|
||||
"watchlists": {
|
||||
"title": "Da vedere",
|
||||
"title": "Watchlists",
|
||||
"my_watchlists": "My Watchlists",
|
||||
"public_watchlists": "Public Watchlists",
|
||||
"create_title": "Create Watchlist",
|
||||
"edit_title": "Edit Watchlist",
|
||||
"create_button": "Create Watchlist",
|
||||
"save_button": "Save Changes",
|
||||
"delete_button": "Cancella",
|
||||
"remove_button": "Rimuovi",
|
||||
"cancel_button": "Annulla",
|
||||
"name_label": "Nome",
|
||||
"name_placeholder": "Inserisci il nome della lista \"Da vedere\"",
|
||||
"description_label": "Descrizione",
|
||||
"description_placeholder": "Inserisci descrizione (opzionale)",
|
||||
"delete_button": "Delete",
|
||||
"remove_button": "Remove",
|
||||
"cancel_button": "Cancel",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter watchlist name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "Enter description (optional)",
|
||||
"is_public_label": "Public Watchlist",
|
||||
"is_public_description": "Permetti ad altri di vedere questa lista",
|
||||
"is_public_description": "Allow others to view this watchlist",
|
||||
"allowed_type_label": "Content Type",
|
||||
"sort_order_label": "Default Sort Order",
|
||||
"empty_title": "No Watchlists",
|
||||
"empty_description": "Crea la tua prima lista \"Da vedere\" per iniziare a organizzare i tuoi media",
|
||||
"empty_watchlist": "Questa lista è vuota",
|
||||
"empty_watchlist_hint": "Aggiungi elementi dalla tua libreria a questa lista",
|
||||
"empty_description": "Create your first watchlist to start organizing your media",
|
||||
"empty_watchlist": "This watchlist is empty",
|
||||
"empty_watchlist_hint": "Add items from your library to this watchlist",
|
||||
"not_configured_title": "Streamystats Not Configured",
|
||||
"not_configured_description": "Configura Streamystats nelle impostazioni per utilizzare le watchlist",
|
||||
"go_to_settings": "Vai alle impostazioni",
|
||||
"not_configured_description": "Configure Streamystats in settings to use watchlists",
|
||||
"go_to_settings": "Go to Settings",
|
||||
"add_to_watchlist": "Add to Watchlist",
|
||||
"remove_from_watchlist": "Remove from Watchlist",
|
||||
"select_watchlist": "Select Watchlist",
|
||||
"create_new": "Create New Watchlist",
|
||||
"item": "elemento",
|
||||
"items": "elementi",
|
||||
"public": "Pubblico",
|
||||
"private": "Privato",
|
||||
"you": "Tu",
|
||||
"by_owner": "Da un altro utente",
|
||||
"not_found": "\"Da vedere\" non trovata",
|
||||
"item": "item",
|
||||
"items": "items",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
"you": "You",
|
||||
"by_owner": "By another user",
|
||||
"not_found": "Watchlist not found",
|
||||
"delete_confirm_title": "Delete Watchlist",
|
||||
"delete_confirm_message": "Sei sicuro di voler eliminare\"{{name}}\"? Questa azione non può essere annullata.",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"remove_item_title": "Remove from Watchlist",
|
||||
"remove_item_message": "Rimuovere \"{{name}}\" da questa lista?",
|
||||
"loading": "Caricamento liste...",
|
||||
"no_compatible_watchlists": "Nessuna lista compatibile",
|
||||
"create_one_first": "Crea una lista che accetti questo tipo di contenuto"
|
||||
"remove_item_message": "Remove \"{{name}}\" from this watchlist?",
|
||||
"loading": "Loading watchlists...",
|
||||
"no_compatible_watchlists": "No compatible watchlists",
|
||||
"create_one_first": "Create a watchlist that accepts this content type"
|
||||
},
|
||||
"playback_speed": {
|
||||
"title": "Playback Speed",
|
||||
"apply_to": "Apply To",
|
||||
"speed": "Velocità",
|
||||
"speed": "Speed",
|
||||
"scope": {
|
||||
"media": "Solo questo media",
|
||||
"show": "Questo show",
|
||||
"all": "Tutti i media (predefinito)"
|
||||
"media": "This media only",
|
||||
"show": "This show",
|
||||
"all": "All media (default)"
|
||||
}
|
||||
},
|
||||
"companion_login": {
|
||||
"title": "Associa con la TV",
|
||||
"align_qr": "Allinea il QR code all'interno del riquadro",
|
||||
"enter_code_manually": "Inserisci il codice manualmente",
|
||||
"pairing_enter_credentials": "Inserire le credenziali per la TV",
|
||||
"pairing_code_label": "Codice di associazione",
|
||||
"title": "Pair with TV",
|
||||
"align_qr": "Align the QR code within the frame",
|
||||
"enter_code_manually": "Enter code manually",
|
||||
"pairing_enter_credentials": "Enter credentials for TV",
|
||||
"pairing_code_label": "Pairing code",
|
||||
"server": "Server",
|
||||
"authorize_button": "Autorizza",
|
||||
"authorizing": "Autorizzando...",
|
||||
"authorize_button": "Authorize",
|
||||
"authorizing": "Authorizing...",
|
||||
"scan_again": "Scan Again",
|
||||
"done": "Fatto",
|
||||
"done": "Done",
|
||||
"success_title": "Authorization Sent",
|
||||
"pairing_tv_connecting": "La TV si sta collegando al tuo account",
|
||||
"pairing_tv_connecting": "The TV is connecting to your account",
|
||||
"error_title": "Authorization Failed",
|
||||
"error_invalid_qr": "QR code non valido. Scansiona il codice di associazione della TV.",
|
||||
"error_generic": "Si è verificato un errore. Riprova.",
|
||||
"error_permission_denied": "Per scansionare i codici QR è necessaria l'autorizzazione della fotocamera.",
|
||||
"login_as": "Accedi come {{username}}?",
|
||||
"on_server": "su {{server}}",
|
||||
"use_different_user": "Usa un altro utente",
|
||||
"open_settings": "Apri le impostazioni"
|
||||
"error_invalid_qr": "Invalid QR code. Please scan the TV pairing code.",
|
||||
"error_generic": "Something went wrong. Please try again.",
|
||||
"error_permission_denied": "Camera permission is required to scan QR codes.",
|
||||
"login_as": "Log in as {{username}}?",
|
||||
"on_server": "on {{server}}",
|
||||
"use_different_user": "Use a different user",
|
||||
"open_settings": "Open Settings"
|
||||
},
|
||||
"pairing": {
|
||||
"pair_with_phone": "Pair with Phone",
|
||||
"pair_with_phone_title": "Login TV",
|
||||
"waiting_for_phone": "In attesa del telefono...",
|
||||
"scan_with_phone": "Scansiona con l'applicazione Streamyfin sul tuo telefono",
|
||||
"logging_in": "Accesso in corso...",
|
||||
"logging_in_description": "Sto connettendo al server"
|
||||
"waiting_for_phone": "Waiting for phone...",
|
||||
"scan_with_phone": "Scan with the Streamyfin app on your phone",
|
||||
"logging_in": "Logging in...",
|
||||
"logging_in_description": "Connecting to your server"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,7 +505,11 @@
|
||||
"episodes": "Episodes",
|
||||
"movies": "Movies",
|
||||
"loading": "Loading…",
|
||||
"seeAll": "See all"
|
||||
"seeAll": "See all",
|
||||
"connect": "Anslut",
|
||||
"connecting": "Ansluter…",
|
||||
"connected": "Ansluten",
|
||||
"not_connected": "Inte ansluten"
|
||||
},
|
||||
"search": {
|
||||
"search": "Sök...",
|
||||
@@ -732,6 +736,10 @@
|
||||
"request_button": "Önska",
|
||||
"are_you_sure_you_want_to_request_all_seasons": "Är du säker på att du vill begära alla säsonger?",
|
||||
"failed_to_login": "Inloggningen Misslyckades",
|
||||
"connect_to_jellyseerr": "Anslut till Jellyseerr",
|
||||
"connect_in_settings": "Jellyseerr är tillgängligt. Anslut i Inställningar för att aktivera förfrågningsfunktioner.",
|
||||
"session_expired": "Sessionen har gått ut",
|
||||
"session_expired_connect_again": "Din Jellyseerr-session har gått ut. Anslut igen i Inställningar.",
|
||||
"cast": "Roller",
|
||||
"details": "Detaljer",
|
||||
"status": "Status",
|
||||
|
||||
Reference in New Issue
Block a user