mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-09 07:28:36 +01:00
Compare commits
3 Commits
fix/androi
...
fix/tv-see
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151a39c7fc | ||
|
|
a4bc67bc23 | ||
|
|
36d18e2bec |
@@ -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";
|
||||
@@ -59,6 +61,52 @@ export default function SettingsTV() {
|
||||
const { showUserSwitchModal } = useTVUserSwitchModal();
|
||||
const typography = useScaledTVTypography();
|
||||
const queryClient = useQueryClient();
|
||||
const { jellyseerrApi, setJellyseerrUser, clearAllJellyseerData } =
|
||||
useJellyseerr();
|
||||
|
||||
// Jellyseerr state
|
||||
const [jellyseerrServerUrl, setJellyseerrServerUrl] = useState(
|
||||
settings.jellyseerrServerUrl || "",
|
||||
);
|
||||
const [jellyseerrPassword, setJellyseerrPassword] = useState("");
|
||||
const { pluginSettings } = useSettings();
|
||||
|
||||
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 +925,81 @@ 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") ||
|
||||
"Enter your Jellyseerr server URL to enable discover and request features."}
|
||||
</Text>
|
||||
<TVSettingsTextInput
|
||||
label={
|
||||
t("home.settings.plugins.jellyseerr.server_url") || "Server URL"
|
||||
}
|
||||
value={jellyseerrServerUrl}
|
||||
placeholder={
|
||||
t("home.settings.plugins.jellyseerr.server_url_placeholder") ||
|
||||
"https://jellyseerr.example.com"
|
||||
}
|
||||
onChangeText={setJellyseerrServerUrl}
|
||||
onBlur={handleJellyseerrUrlBlur}
|
||||
disabled={isJellyseerrLocked || jellyseerrLoginMutation.isPending}
|
||||
/>
|
||||
{!isJellyseerrConnected && !isJellyseerrLocked && (
|
||||
<>
|
||||
<TVSettingsTextInput
|
||||
label={
|
||||
t("home.settings.plugins.jellyseerr.password") || "Password"
|
||||
}
|
||||
value={jellyseerrPassword}
|
||||
placeholder={
|
||||
t("home.settings.plugins.jellyseerr.password_placeholder", {
|
||||
username: user?.Name,
|
||||
}) || `Jellyfin password`
|
||||
}
|
||||
onChangeText={setJellyseerrPassword}
|
||||
secureTextEntry
|
||||
disabled={jellyseerrLoginMutation.isPending}
|
||||
/>
|
||||
<TVSettingsOptionButton
|
||||
label={
|
||||
jellyseerrLoginMutation.isPending
|
||||
? t("common.connecting", "Connecting...") || "Connecting..."
|
||||
: t("common.connect", "Connect") || "Connect"
|
||||
}
|
||||
value=''
|
||||
onPress={() => jellyseerrLoginMutation.mutate()}
|
||||
disabled={jellyseerrLoginMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<TVSettingsRow
|
||||
label={
|
||||
isJellyseerrConnected
|
||||
? t("common.connected", "Connected") || "Connected"
|
||||
: t("common.not_connected", "Not connected") || "Not connected"
|
||||
}
|
||||
value=''
|
||||
showChevron={false}
|
||||
/>
|
||||
{isJellyseerrConnected && !isJellyseerrLocked && (
|
||||
<TVSettingsOptionButton
|
||||
label={
|
||||
t(
|
||||
"home.settings.plugins.jellyseerr.reset_jellyseerr_config_button",
|
||||
) || "Disconnect"
|
||||
}
|
||||
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();
|
||||
|
||||
// Alert when seerr server is configured but user hasn't connected (only when focused)
|
||||
useEffect(() => {
|
||||
if (!isFocused || !settings?.jellyseerrServerUrl || jellyseerrApi) return;
|
||||
Alert.alert(
|
||||
t("jellyseerr.connect_to_jellyseerr", "Connect to Jellyseerr"),
|
||||
t(
|
||||
"jellyseerr.connect_in_settings",
|
||||
"Jellyseerr is available. Connect in Settings to enable request features.",
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Validate jellyseerr session when switching to Discover
|
||||
useEffect(() => {
|
||||
if (
|
||||
searchType !== "Discover" ||
|
||||
!jellyseerrApi ||
|
||||
!settings?.jellyseerrServerUrl
|
||||
)
|
||||
return;
|
||||
validateJellyseerrSession(settings.jellyseerrServerUrl).then((status) => {
|
||||
if (status.valid) return;
|
||||
Alert.alert(
|
||||
t(
|
||||
"jellyseerr.session_expired_connect_again",
|
||||
"Your Jellyseerr session has expired. Please reconnect in Settings.",
|
||||
),
|
||||
);
|
||||
});
|
||||
}, [searchType, jellyseerrApi, settings?.jellyseerrServerUrl, t]);
|
||||
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||
useState<JellyseerrSearchSort>(
|
||||
JellyseerrSearchSort[
|
||||
|
||||
@@ -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 {
|
||||
@@ -22,8 +23,6 @@ import type {
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
interface TVDiscoverPosterProps {
|
||||
item: MovieResult | TvResult;
|
||||
isFirstItem?: boolean;
|
||||
@@ -34,6 +33,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
isFirstItem = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const router = useRouter();
|
||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
@@ -50,6 +50,8 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||
|
||||
const posterWidth = sizes.posters.poster;
|
||||
|
||||
const handlePress = () => {
|
||||
router.push({
|
||||
pathname: "/(auth)/(tabs)/(search)/jellyseerr/page",
|
||||
@@ -71,7 +73,7 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
@@ -81,9 +83,9 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
borderRadius: sizes.gaps.small,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
}}
|
||||
@@ -140,12 +142,12 @@ const TVDiscoverPoster: React.FC<TVDiscoverPosterProps> = ({
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{year && (
|
||||
{year != null && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
color: "#9CA3AF",
|
||||
marginTop: 2,
|
||||
marginTop: sizes.gaps.small,
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
@@ -166,6 +168,7 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
isFirstSlide = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const { t } = useTranslation();
|
||||
const { jellyseerrApi, isJellyseerrMovieOrTvResult } = useJellyseerr();
|
||||
|
||||
@@ -231,14 +234,14 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
if (!flatData || flatData.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginBottom: sizes.gaps.small,
|
||||
marginLeft: sizes.padding.scale,
|
||||
}}
|
||||
>
|
||||
{slideTitle}
|
||||
@@ -249,9 +252,9 @@ export const TVDiscoverSlide: React.FC<TVDiscoverSlideProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
paddingHorizontal: sizes.padding.scale,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: sizes.gaps.item,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
onEndReached={() => {
|
||||
|
||||
@@ -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";
|
||||
@@ -14,8 +15,6 @@ import type {
|
||||
TvResult,
|
||||
} from "@/utils/jellyseerr/server/models/Search";
|
||||
|
||||
const SCALE_PADDING = 20;
|
||||
|
||||
interface TVJellyseerrPosterProps {
|
||||
item: MovieResult | TvResult;
|
||||
onPress: () => void;
|
||||
@@ -28,6 +27,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
isFirstItem = false,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const { jellyseerrApi, getTitle, getYear } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation({ scaleAmount: 1.05 });
|
||||
@@ -43,6 +43,8 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
item.mediaInfo?.status === MediaStatus.AVAILABLE ||
|
||||
item.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE;
|
||||
|
||||
const posterWidth = sizes.posters.poster;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -54,7 +56,7 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: focused ? 0.6 : 0,
|
||||
@@ -64,9 +66,9 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 210,
|
||||
width: posterWidth,
|
||||
aspectRatio: 10 / 15,
|
||||
borderRadius: 24,
|
||||
borderRadius: sizes.gaps.small,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
}}
|
||||
@@ -117,13 +119,13 @@ const TVJellyseerrPoster: React.FC<TVJellyseerrPosterProps> = ({
|
||||
fontSize: typography.callout,
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
marginTop: sizes.gaps.small,
|
||||
}}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{year && (
|
||||
{year != null && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.callout,
|
||||
@@ -149,6 +151,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
onPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const { focused, handleFocus, handleBlur, animatedStyle } =
|
||||
useTVFocusAnimation();
|
||||
@@ -157,13 +160,15 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
? jellyseerrApi?.imageProxy(item.profilePath, "w185")
|
||||
: null;
|
||||
|
||||
const avatarSize = Math.round(sizes.posters.poster * 0.67);
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress} onFocus={handleFocus} onBlur={handleBlur}>
|
||||
<Animated.View
|
||||
style={[
|
||||
animatedStyle,
|
||||
{
|
||||
width: 160,
|
||||
width: avatarSize,
|
||||
alignItems: "center",
|
||||
shadowColor: "#fff",
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
@@ -174,9 +179,9 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 70,
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
borderRadius: avatarSize / 2,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
borderWidth: focused ? 3 : 0,
|
||||
@@ -198,7 +203,11 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name='person' size={56} color='rgba(255,255,255,0.4)' />
|
||||
<Ionicons
|
||||
name='person'
|
||||
size={Math.round(avatarSize * 0.35)}
|
||||
color='rgba(255,255,255,0.4)'
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -207,7 +216,7 @@ const TVJellyseerrPersonPoster: React.FC<TVJellyseerrPersonPosterProps> = ({
|
||||
fontSize: typography.callout,
|
||||
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
|
||||
fontWeight: "600",
|
||||
marginTop: 12,
|
||||
marginTop: sizes.gaps.small,
|
||||
textAlign: "center",
|
||||
}}
|
||||
numberOfLines={2}
|
||||
@@ -233,17 +242,18 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginBottom: sizes.gaps.small,
|
||||
marginLeft: sizes.padding.scale,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -254,9 +264,9 @@ const TVJellyseerrMovieSection: React.FC<TVJellyseerrMovieSectionProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
paddingHorizontal: sizes.padding.scale,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: sizes.gaps.item,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
@@ -285,17 +295,18 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginBottom: sizes.gaps.small,
|
||||
marginLeft: sizes.padding.scale,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -306,9 +317,9 @@ const TVJellyseerrTvSection: React.FC<TVJellyseerrTvSectionProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
paddingHorizontal: sizes.padding.scale,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: sizes.gaps.item,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item, index }) => (
|
||||
@@ -337,17 +348,18 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
onItemPress,
|
||||
}) => {
|
||||
const typography = useScaledTVTypography();
|
||||
const sizes = useScaledTVSizes();
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={{ marginBottom: 24 }}>
|
||||
<View style={{ marginBottom: sizes.gaps.section }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 16,
|
||||
marginLeft: SCALE_PADDING,
|
||||
marginBottom: sizes.gaps.small,
|
||||
marginLeft: sizes.padding.scale,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
@@ -358,9 +370,9 @@ const TVJellyseerrPersonSection: React.FC<TVJellyseerrPersonSectionProps> = ({
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: SCALE_PADDING,
|
||||
paddingVertical: SCALE_PADDING,
|
||||
gap: 20,
|
||||
paddingHorizontal: sizes.padding.scale,
|
||||
paddingVertical: sizes.padding.scale,
|
||||
gap: sizes.gaps.item,
|
||||
}}
|
||||
style={{ overflow: "visible" }}
|
||||
renderItem={({ item }) => (
|
||||
@@ -400,6 +412,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
onPersonPress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const typography = useScaledTVTypography();
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
@@ -410,7 +423,7 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
<View style={{ alignItems: "center", paddingTop: 40 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 24,
|
||||
fontSize: typography.heading,
|
||||
fontWeight: "bold",
|
||||
color: "#FFFFFF",
|
||||
marginBottom: 8,
|
||||
@@ -418,7 +431,9 @@ export const TVJellyseerrSearchResults: React.FC<
|
||||
>
|
||||
{t("search.no_results_found_for")}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 18, color: "rgba(255,255,255,0.6)" }}>
|
||||
<Text
|
||||
style={{ fontSize: typography.body, color: "rgba(255,255,255,0.6)" }}
|
||||
>
|
||||
"{searchQuery}"
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, TextInput, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -166,6 +166,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
|
||||
// Image URL getter for music items
|
||||
const getImageUrl = useMemo(() => {
|
||||
@@ -270,6 +271,9 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
onChangeText={setSearch}
|
||||
defaultValue=''
|
||||
autoFocus={false}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setIsSearchFocused(false)}
|
||||
hasTVPreferredFocus
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -290,6 +294,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
searchType={searchType}
|
||||
setSearchType={setSearchType}
|
||||
showDiscover={showDiscover}
|
||||
disabled={isSearchFocused}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -316,6 +321,7 @@ export const TVSearchPage: React.FC<TVSearchPageProps> = ({
|
||||
// every keystroke as results re-render. User navigates down to the
|
||||
// grid manually.
|
||||
isFirstSection={false}
|
||||
disabled={isSearchFocused}
|
||||
onItemPress={onItemPress}
|
||||
onItemLongPress={onItemLongPress}
|
||||
imageUrlGetter={
|
||||
|
||||
@@ -70,6 +70,36 @@ export const clearJellyseerrStorageData = () => {
|
||||
storage.remove(JELLYSEERR_COOKIES);
|
||||
};
|
||||
|
||||
export type JellyseerrSessionStatus =
|
||||
| { valid: true }
|
||||
| { valid: false; reason: "no_session" | "expired" };
|
||||
|
||||
export async function validateJellyseerrSession(
|
||||
serverUrl: string,
|
||||
): Promise<JellyseerrSessionStatus> {
|
||||
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
|
||||
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
|
||||
|
||||
console.log(
|
||||
"Validating Jellyseerr session with server URL:",
|
||||
serverUrl,
|
||||
!user,
|
||||
!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 +480,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) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||
<application>
|
||||
<receiver android:name=".TvRecommendationsReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
|
||||
@@ -247,12 +247,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
}, [api, secret, headers, jellyfin]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await refreshStreamyfinPluginSettings();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
store.set(apiAtom, api);
|
||||
}, [api]);
|
||||
@@ -553,7 +547,20 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
);
|
||||
|
||||
// Refresh plugin settings
|
||||
await refreshStreamyfinPluginSettings();
|
||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
||||
const jellyseerrApi = new JellyseerrApi(
|
||||
recentPluginSettings.jellyseerrServerUrl.value,
|
||||
);
|
||||
await jellyseerrApi.test().then((result) => {
|
||||
if (result.isValid && result.requiresPass) {
|
||||
jellyseerrApi
|
||||
.login(username, password)
|
||||
.then(setJellyseerrUser)
|
||||
.catch(console.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -592,7 +592,8 @@
|
||||
"continue": "Continue",
|
||||
"verifying": "Verifying...",
|
||||
"login": "Login",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"search": {
|
||||
"search": "Search...",
|
||||
@@ -821,7 +822,10 @@
|
||||
"report_issue_button": "Report issue",
|
||||
"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",
|
||||
"failed_to_login": "Failed to Login",
|
||||
"connect_to_jellyseerr": "Connect to Jellyseerr",
|
||||
"session_expired_connect_again": "Your Jellyseerr session has expired. Please reconnect in Settings.",
|
||||
"connect_in_settings": "Jellyseerr is available. Connect in Settings to enable request features.",
|
||||
"cast": "Cast",
|
||||
"details": "Details",
|
||||
"status": "Status",
|
||||
|
||||
Reference in New Issue
Block a user