Compare commits

...

7 Commits

Author SHA1 Message Date
Fredrik Burmester
7f07260177 fix: hide setting (use only vlc3 for now) 2025-03-03 15:18:09 +01:00
herrrta
09e9462ac0 feat: (iOS) Switch Video Players 2025-03-03 01:12:08 -05:00
herrrta
dd65505f7f feat: [Jellyseerr] Show recent requests #324
- Added recent requests slide
- updated JellyseerrPoster.tsx to handle more options
2025-03-03 01:04:49 -05:00
herrrta
951158bcd3 fix: Discover page key collisions #581
- add uniqBy for jellyseerr results
- add missing key in MovieTvSlide.tsx
2025-03-02 13:07:14 -05:00
herrrta
9b1dd0923a fix: Don't show all seasons numbers in request modal [Jellyseerr] #580 2025-03-02 12:38:58 -05:00
herrrta
bd908516b5 fix: Advanced request options not saving #543 2025-03-02 12:21:24 -05:00
lostb1t
8cb10d1062 Update _layout.tsx 2025-03-01 09:37:50 +01:00
54 changed files with 1145 additions and 408 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ npm-debug.*
*.orig.*
web-build/
modules/vlc-player/android/build
modules/vlc-player/android/.gradle
bun.lockb
# macOS

View File

@@ -78,12 +78,6 @@ export default function IndexLayout() {
title: "",
}}
/>
<Stack.Screen
name="settings/dashboard/sessions"
options={{
title: t("home.settings.dashboard.sessions_title"),
}}
/>
<Stack.Screen
name="settings/jellyseerr/page"
options={{

View File

@@ -42,25 +42,28 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const { t } = useTranslation();
const { mediaTitle, releaseYear, posterSrc, ...result } =
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
mediaType: MediaType;
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
const navigation = useNavigation();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
const advancedReqModalRef = useRef<BottomSheetModal>(null);
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -71,7 +74,7 @@ const Page: React.FC = () => {
refetch,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
queryKey: ["jellyseerr", "detail", mediaType, result.id],
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
@@ -79,7 +82,7 @@ const Page: React.FC = () => {
retryOnMount: true,
refetchInterval: 0,
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!!);
},
@@ -111,10 +114,15 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const setRequestBody = useCallback((body: MediaRequestBody) => {
_setRequestBody(body)
advancedReqModalRef?.current?.present?.();
}, [requestBody, _setRequestBody, advancedReqModalRef])
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
mediaType: mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
@@ -122,7 +130,7 @@ const Page: React.FC = () => {
};
if (hasAdvancedRequestPermission) {
advancedReqModalRef?.current?.present?.(body);
setRequestBody(body)
return;
}
@@ -132,7 +140,7 @@ const Page: React.FC = () => {
const isAnime = useMemo(
() =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
result.mediaType === MediaType.TV,
mediaType === MediaType.TV,
[details]
);
@@ -200,7 +208,7 @@ const Page: React.FC = () => {
<View className="px-4">
<View className="flex flex-row justify-between w-full">
<View className="flex flex-col w-56">
<JellyserrRatings result={result as MovieResult | TvResult} />
<JellyserrRatings result={result as MovieResult | TvResult | MovieDetails | TvDetails} />
<Text
uiTextView
selectable
@@ -247,15 +255,14 @@ const Page: React.FC = () => {
<OverviewText text={result.overview} className="mt-4" />
</View>
{result.mediaType === MediaType.TV && (
{mediaType === MediaType.TV && (
<JellyseerrSeasons
isLoading={isLoading || isFetching}
result={result as TvResult}
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
onAdvancedRequest={(data) =>
advancedReqModalRef?.current?.present(data)
setRequestBody(data)
}
/>
)}
@@ -269,14 +276,17 @@ const Page: React.FC = () => {
</ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
requestBody={requestBody}
title={mediaTitle}
id={result.id!!}
type={result.mediaType as MediaType}
type={mediaType}
isAnime={isAnime}
onRequested={() => {
_setRequestBody(undefined)
advancedReqModalRef?.current?.close();
refetch();
}}
onDismiss={() => _setRequestBody(undefined)}
/>
<BottomSheetModal
ref={bottomSheetModalRef}

View File

@@ -5,13 +5,13 @@ import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules/vlc-player";
import { VlcPlayerView } from "@/modules";
import {
PipStartedPayload,
PlaybackStatePayload,
ProgressUpdatePayload,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
} from "@/modules/VlcPlayer.types";
const downloadProvider = !Platform.isTV ? require("@/providers/DownloadProvider") : null;
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";

View File

@@ -21,14 +21,19 @@ export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"], t
);
};
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
export const Tags: React.FC<TagProps & {tagProps?: ViewProps} & ViewProps> = ({
tags,
textClass = "text-xs",
tagProps,
...props
}) => {
if (!tags || tags.length === 0) return null;
return (
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
{tags.map((tag, idx) => (
<View key={idx}>
<Tag key={idx} textClass={textClass} text={tag}/>
<Tag key={idx} textClass={textClass} text={tag} {...tagProps}/>
</View>
))}
</View>

View File

@@ -7,6 +7,9 @@ import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useQuery } from "@tanstack/react-query";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {useMemo} from "react";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -49,14 +52,17 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
);
};
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult | TvDetails | MovieDetails }> = ({
result,
}) => {
const { jellyseerrApi } = useJellyseerr();
const { jellyseerrApi, getMediaType } = useJellyseerr();
const mediaType = useMemo(() => getMediaType(result), [result]);
const { data, isLoading } = useQuery({
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
queryKey: ["jellyseerr", result.id, mediaType, "ratings"],
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
return mediaType === MediaType.MOVIE
? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id);
},

View File

@@ -18,7 +18,7 @@ interface Props<T> {
title: string | ReactNode;
label: string;
onSelected: (...item: T[]) => void;
multi?: boolean;
multiple?: boolean;
}
const Dropdown = <T extends unknown>({
@@ -30,7 +30,7 @@ const Dropdown = <T extends unknown>({
title,
label,
onSelected,
multi = false,
multiple = false,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
if (Platform.isTV) return null;
@@ -72,7 +72,7 @@ const Dropdown = <T extends unknown>({
>
<DropdownMenu.Label>{label}</DropdownMenu.Label>
{data.map((item, idx) =>
multi ? (
multiple ? (
<DropdownMenu.CheckboxItem
value={
selected?.some((s) => keyExtractor(s) == keyExtractor(item))
@@ -80,7 +80,7 @@ const Dropdown = <T extends unknown>({
: "off"
}
key={keyExtractor(item)}
onValueChange={(next, previous) =>
onValueChange={(next: "on" | "off", previous: "on" | "off") => {
setSelected((p) => {
const prev = p || [];
if (next == "on") {
@@ -92,7 +92,7 @@ const Dropdown = <T extends unknown>({
),
];
})
}
}}
>
<DropdownMenu.ItemTitle>
{titleExtractor(item)}

View File

@@ -9,13 +9,16 @@ import {
Permission,
} from "@/utils/jellyseerr/server/lib/permissions";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
interface Props extends TouchableOpacityProps {
result: MovieResult | TvResult;
result: MovieResult | TvResult | MovieDetails | TvDetails;
mediaTitle: string;
releaseYear: number;
canRequest: boolean;
posterSrc: string;
mediaType: MediaType;
}
export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
@@ -24,6 +27,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
releaseYear,
canRequest,
posterSrc,
mediaType,
children,
...props
}) => {
@@ -46,7 +50,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
() =>
requestMedia(mediaTitle, {
mediaId: result.id,
mediaType: result.mediaType,
mediaType,
}),
[jellyseerrApi, result]
);
@@ -67,6 +71,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
releaseYear,
canRequest,
posterSrc,
mediaType
},
});
}}
@@ -83,7 +88,7 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
key={"content"}
>
<ContextMenu.Label key="label-1">Actions</ContextMenu.Label>
{canRequest && result.mediaType === MediaType.MOVIE && (
{canRequest && mediaType === MediaType.MOVIE && (
<ContextMenu.Item
key="item-1"
onSelect={() => {

View File

@@ -21,6 +21,7 @@ import { LoadingSkeleton } from "../search/LoadingSkeleton";
import { SearchItemWrapper } from "../search/SearchItemWrapper";
import PersonPoster from "./PersonPoster";
import { useTranslation } from "react-i18next";
import {uniqBy} from "lodash";
interface Props extends ViewProps {
searchQuery: string;
@@ -77,25 +78,28 @@ export const JellyserrIndexPage: React.FC<Props> = ({ searchQuery }) => {
const jellyseerrMovieResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE
) as MovieResult[],
uniqBy(
jellyseerrResults?.filter((r) => r.mediaType === MediaType.MOVIE) as MovieResult[],
"id"
),
[jellyseerrResults]
);
const jellyseerrTvResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV
) as TvResult[],
uniqBy(
jellyseerrResults?.filter((r) => r.mediaType === MediaType.TV) as TvResult[],
"id"
),
[jellyseerrResults]
);
const jellyseerrPersonResults = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === "person"
) as PersonResult[],
uniqBy(
jellyseerrResults?.filter((r) => r.mediaType === "person") as PersonResult[],
"id"
),
[jellyseerrResults]
);

View File

@@ -15,18 +15,22 @@ import { useTranslation } from "react-i18next";
interface Props {
id: number;
title: string,
requestBody?: MediaRequestBody,
type: MediaType;
isAnime?: boolean;
is4k?: boolean;
onRequested?: () => void;
onDismiss?: () => void;
}
const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps, 'id'>>(({
id,
title,
requestBody,
type,
isAnime = false,
onRequested,
onDismiss,
...props
}, ref) => {
const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr();
@@ -39,8 +43,6 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
const { t } = useTranslation();
const [modalRequestProps, setModalRequestProps] = useState<MediaRequestBody>();
const {data: serviceSettings} = useQuery({
queryKey: ["jellyseerr", "request", type, 'service'],
queryFn: async () => jellyseerrApi?.service(type == 'movie' ? 'radarr' : 'sonarr'),
@@ -98,16 +100,19 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
: defaultServiceDetails?.server.activeTags
)?.includes(t.id)
) ?? []
console.log(tags)
return tags
},
[defaultServiceDetails]
);
const seasonTitle = useMemo(
() => modalRequestProps?.seasons?.length ? t("jellyseerr.season_x", {seasons: modalRequestProps?.seasons}) : undefined,
[modalRequestProps?.seasons]
() => {
if (requestBody?.seasons && requestBody?.seasons?.length > 1) {
return t("jellyseerr.season_all")
}
return t("jellyseerr.season_number", {season_number: requestBody?.seasons})
},
[requestBody?.seasons]
);
const request = useCallback(() => {requestMedia(
@@ -117,12 +122,12 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
profileId: defaultProfile.id,
rootFolder: defaultFolder.path,
tags: defaultTags.map(t => t.id),
...modalRequestProps,
...requestBody,
...requestOverrides
},
onRequested
)
}, [modalRequestProps, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
}, [requestBody, requestOverrides, defaultProfile, defaultFolder, defaultTags]);
const pathTitleExtractor = (item: RootFolder) => `${item.path} (${item.freeSpace.bytesToReadable()})`;
@@ -131,7 +136,7 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
ref={ref}
enableDynamicSizing
enableDismissOnClose
onDismiss={() => setModalRequestProps(undefined)}
onDismiss={onDismiss}
handleIndicatorStyle={{
backgroundColor: "white",
}}
@@ -146,89 +151,86 @@ const RequestModal = forwardRef<BottomSheetModalMethods, Props & Omit<ViewProps,
/>
}
>
{(data) => {
setModalRequestProps(data?.data as MediaRequestBody)
return <BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text>
{seasonTitle &&
<Text className="text-neutral-300">{seasonTitle}</Text>
}
</View>
<View className="flex flex-col space-y-2">
{(defaultService && defaultServiceDetails && users) && (
<>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={defaultProfile.name}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.quality_profile")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
profileId: item?.id
}))
}
title={t("jellyseerr.quality_profile")}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path
}))}
title={t("jellyseerr.root_folder")}
/>
<Dropdown
multi={true}
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.tags")}
onSelected={(...item) =>
item && setRequestOverrides((prev) => ({
...prev,
tags: item.map(i => i.id)
}))
}
title={t("jellyseerr.tags")}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={t("jellyseerr.request_as")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
userId: item?.id
}))
}
title={t("jellyseerr.request_as")}
/>
</>
)
}
</View>
<Button
className="mt-auto"
onPress={request}
color="purple"
>
{t("jellyseerr.request_button")}
</Button>
<BottomSheetView>
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
<View>
<Text className="font-bold text-2xl text-neutral-100">{t("jellyseerr.advanced")}</Text>
{seasonTitle &&
<Text className="text-neutral-300">{seasonTitle}</Text>
}
</View>
</BottomSheetView>
}}
<View className="flex flex-col space-y-2">
{(defaultService && defaultServiceDetails && users) && (
<>
<Dropdown
data={defaultServiceDetails.profiles}
titleExtractor={(item) => item.name}
placeholderText={requestOverrides.profileName || defaultProfile.name}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.quality_profile")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
profileId: item?.id
}))
}
title={t("jellyseerr.quality_profile")}
/>
<Dropdown
data={defaultServiceDetails.rootFolders}
titleExtractor={pathTitleExtractor}
placeholderText={defaultFolder ? pathTitleExtractor(defaultFolder) : ""}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.root_folder")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
rootFolder: item.path
}))}
title={t("jellyseerr.root_folder")}
/>
<Dropdown
multiple
data={defaultServiceDetails.tags}
titleExtractor={(item) => item.label}
placeholderText={defaultTags.map(t => t.label).join(",")}
keyExtractor={(item) => item.id.toString()}
label={t("jellyseerr.tags")}
onSelected={(...selected) =>
setRequestOverrides((prev) => ({
...prev,
tags: selected.map(i => i.id)
}))
}
title={t("jellyseerr.tags")}
/>
<Dropdown
data={users}
titleExtractor={(item) => item.displayName}
placeholderText={jellyseerrUser!!.displayName}
keyExtractor={(item) => item.id.toString() || ""}
label={t("jellyseerr.request_as")}
onSelected={(item) =>
item && setRequestOverrides((prev) => ({
...prev,
userId: item?.id
}))
}
title={t("jellyseerr.request_as")}
/>
</>
)
}
</View>
<Button
className="mt-auto"
onPress={request}
color="purple"
>
{t("jellyseerr.request_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
);
});

View File

@@ -8,6 +8,7 @@ import {View} from "react-native";
import {networks} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import {studios} from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
import GenreSlide from "@/components/jellyseerr/discover/GenreSlide";
import RecentRequestsSlide from "@/components/jellyseerr/discover/RecentRequestsSlide";
interface Props {
sliders?: DiscoverSlider[];
@@ -25,6 +26,8 @@ const Discover: React.FC<Props> = ({ sliders }) => {
<View className="flex flex-col space-y-4 mb-8">
{sortedSliders.map(slide => {
switch (slide.type) {
case DiscoverSliderType.RECENT_REQUESTS:
return <RecentRequestsSlide key={slide.id} slide={slide} />
case DiscoverSliderType.NETWORKS:
return <CompanySlide key={slide.id} slide={slide} data={networks}/>
case DiscoverSliderType.STUDIOS:

View File

@@ -10,6 +10,7 @@ import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {ViewProps} from "react-native";
import {uniqBy} from "lodash";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
@@ -57,7 +58,11 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
});
const flatData = useMemo(
() => data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
() =>
uniqBy(
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
"id"
),
[data]
);
@@ -74,7 +79,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) =>
fetchNextPage()
}}
renderItem={(item) =>
<JellyseerrPoster item={item as MovieResult | TvResult} />
<JellyseerrPoster item={item as MovieResult | TvResult} key={item?.id}/>
}
/>
)

View File

@@ -0,0 +1,69 @@
import React from "react";
import {useQuery} from "@tanstack/react-query";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import Slide, {SlideProps} from "@/components/jellyseerr/discover/Slide";
import {ViewProps} from "react-native";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import {NonFunctionProperties} from "@/utils/jellyseerr/server/interfaces/api/common";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
const RequestCard: React.FC<{request: MediaRequest}> = ({request}) => {
const {jellyseerrApi} = useJellyseerr();
const { data: details, isLoading, isError } = useQuery({
queryKey: ["jellyseerr", "detail", request.media.mediaType, request.media.tmdbId],
queryFn: async () => {
return request.media.mediaType == MediaType.MOVIE
? jellyseerrApi?.movieDetails(request.media.tmdbId)
: jellyseerrApi?.tvDetails(request.media.tmdbId);
},
enabled: !!jellyseerrApi,
refetchOnMount: true,
staleTime: 0,
});
const { data: refreshedRequest } = useQuery({
queryKey: ["jellyseerr", "requests", request.media.mediaType, request.id],
queryFn: async () => jellyseerrApi?.getRequest(request.id),
enabled: !!jellyseerrApi,
refetchOnMount: true,
refetchInterval: 5000,
staleTime: 0,
});
return (
details && <JellyseerrPoster horizontal showDownloadInfo item={details} mediaRequest={refreshedRequest} />
)
}
const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const {jellyseerrApi} = useJellyseerr();
const { data: requests, isLoading, isError } = useQuery({
queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => jellyseerrApi?.requests(),
enabled: !!jellyseerrApi,
refetchOnMount: true,
staleTime: 0,
});
return (
requests &&
requests.results.length > 0 &&
!isError && (
<Slide
{...props}
slide={slide}
data={requests.results}
keyExtractor={(item) => item.id.toString()}
renderItem={(item: NonFunctionProperties<MediaRequest>) => (
<RequestCard request={item}/>
)}
/>
)
)
};
export default RecentRequestsSlide;

View File

@@ -1,28 +1,42 @@
import { TouchableJellyseerrRouter } from "@/components/common/JellyseerrItemRouter";
import { Text } from "@/components/common/Text";
import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
import {Text} from "@/components/common/Text";
import JellyseerrMediaIcon from "@/components/jellyseerr/JellyseerrMediaIcon";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { Image } from "expo-image";
import { useMemo } from "react";
import { View, ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {useJellyseerrCanRequest} from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {Image} from "expo-image";
import {useMemo} from "react";
import {View, ViewProps} from "react-native";
import Animated, {useAnimatedStyle, useSharedValue, withTiming,} from "react-native-reanimated";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie";
import type {DownloadingItem} from "@/utils/jellyseerr/server/lib/downloadtracker";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import {useTranslation} from "react-i18next";
import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
import {textShadowStyle} from "@/components/jellyseerr/discover/GenericSlideCard";
import {Colors} from "@/constants/Colors";
import {Tags} from "@/components/GenreTags";
interface Props extends ViewProps {
item: MovieResult | TvResult;
item: MovieResult | TvResult | MovieDetails | TvDetails;
horizontal?: boolean;
showDownloadInfo?: boolean;
mediaRequest?: MediaRequest;
}
const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
const { jellyseerrApi } = useJellyseerr();
const JellyseerrPoster: React.FC<Props> = ({
item,
horizontal,
showDownloadInfo,
mediaRequest,
...props
}) => {
const { jellyseerrApi, getTitle, getYear, getMediaType, isJellyseerrResult } = useJellyseerr();
const loadingOpacity = useSharedValue(1);
const imageOpacity = useSharedValue(0);
const {t} = useTranslation();
const loadingAnimatedStyle = useAnimatedStyle(() => ({
opacity: loadingOpacity.value,
@@ -38,27 +52,64 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
};
const imageSrc = useMemo(
() => jellyseerrApi?.imageProxy(item.posterPath, "w300_and_h450_face"),
[item, jellyseerrApi]
() => jellyseerrApi?.imageProxy(
horizontal ? item.backdropPath : item.posterPath,
horizontal ? "w1920_and_h800_multi_faces" : "w300_and_h450_face"
),
[item, jellyseerrApi, horizontal]
);
const title = useMemo(
() => (item.mediaType === MediaType.MOVIE ? item.title : item.name),
[item]
);
const title = useMemo(() => getTitle(item), [item]);
const releaseYear = useMemo(() => getYear(item), [item]);
const mediaType = useMemo(() => getMediaType(item), [item]);
const releaseYear = useMemo(
() =>
new Date(
item.mediaType === MediaType.MOVIE
? item.releaseDate
: item.firstAirDate
).getFullYear(),
[item]
);
const size = useMemo(() => horizontal ? 'h-28' : 'w-28', [horizontal])
const ratio = useMemo(() => horizontal ? '15/10' : '10/15', [horizontal])
const [canRequest] = useJellyseerrCanRequest(item);
const is4k = useMemo(
() => mediaRequest?.is4k === true,
[mediaRequest]
);
const downloadItems = useMemo(
() => (is4k ? mediaRequest?.media.downloadStatus4k : mediaRequest?.media.downloadStatus) || [],
[mediaRequest, is4k]
)
const progress = useMemo(() => {
const [totalSize, sizeLeft] = downloadItems
.reduce((sum: number[], next: DownloadingItem) =>
[sum[0] + next.size, sum[1] + next.sizeLeft],
[0, 0]
);
return (((totalSize - sizeLeft) / totalSize) * 100);
},
[downloadItems]
);
const requestedSeasons: string[] | undefined = useMemo(
() => {
const seasons = mediaRequest?.seasons?.flatMap(s => s.seasonNumber.toString()) || []
if (seasons.length > 4) {
const [first, second, third, fourth, ...rest] = seasons;
return [first, second, third, fourth, t("home.settings.plugins.jellyseerr.plus_n_more", {n: rest.length })]
}
return seasons
},
[mediaRequest]
);
const available = useMemo(
() => {
const status = mediaRequest?.media?.[is4k ? 'status4k' : 'status'];
return status === MediaStatus.AVAILABLE
},
[mediaRequest, is4k]
);
return (
<TouchableJellyseerrRouter
result={item}
@@ -66,9 +117,10 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
releaseYear={releaseYear}
canRequest={canRequest}
posterSrc={imageSrc!!}
mediaType={mediaType}
>
<View className="flex flex-col w-28 mr-2">
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
<View className={`flex flex-col mr-2 h-auto`}>
<View className={`relative rounded-lg overflow-hidden border border-neutral-900 ${size} aspect-[${ratio}]`}>
<Animated.View style={imageAnimatedStyle}>
<Image
key={item.id}
@@ -77,26 +129,65 @@ const JellyseerrPoster: React.FC<Props> = ({ item, ...props }) => {
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
aspectRatio: ratio,
[horizontal ? 'height' : 'width']: "100%"
}}
onLoad={handleImageLoad}
/>
</Animated.View>
{mediaRequest && showDownloadInfo && (
<>
<View className={`absolute w-full h-full bg-black ${!available ? 'opacity-70' : 'opacity-0'}`} />
{!available && !Number.isNaN(progress) && (
<>
<View
className="absolute left-0 h-full opacity-40"
style={{
width: `${progress || 0}%`,
backgroundColor: Colors.primaryRGB,
}}
/>
<View className="absolute w-full h-full justify-center items-center">
<Text
className="font-bold"
style={textShadowStyle.shadow}
>
{progress?.toFixed(0)}%
</Text>
</View>
</>
)}
<Text
className="absolute right-1 top-1 text-right font-bold"
style={textShadowStyle.shadow}
>
{mediaRequest?.requestedBy.displayName}
</Text>
{requestedSeasons.length > 0 && (
<Tags
className="absolute bottom-1 left-0.5 w-32"
tagProps={{
className: "bg-black rounded-full px-1"
}}
tags={requestedSeasons}
/>
)}
</>
)}
<JellyseerrStatusIcon
className="absolute bottom-1 right-1"
showRequestIcon={canRequest}
mediaStatus={item?.mediaInfo?.status}
mediaStatus={mediaRequest?.media?.status || item?.mediaInfo?.status}
/>
<JellyseerrMediaIcon
className="absolute top-1 left-1"
mediaType={item?.mediaType}
mediaType={mediaType}
/>
</View>
<View className="mt-2 flex flex-col">
<Text numberOfLines={2}>{title}</Text>
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
</View>
</View>
<View className={`mt-2 flex flex-col ${horizontal ? 'w-44' : 'w-28'}`}>
<Text numberOfLines={2}>{title}</Text>
<Text className="text-xs opacity-50 align-bottom">{releaseYear}</Text>
</View>
</TouchableJellyseerrRouter>
);

View File

@@ -128,14 +128,12 @@ const RenderItem = ({ item, index }: any) => {
const JellyseerrSeasons: React.FC<{
isLoading: boolean;
result?: TvResult;
details?: TvDetails;
hasAdvancedRequest?: boolean,
onAdvancedRequest?: (data: MediaRequestBody) => void;
refetch: (options?: (RefetchOptions | undefined)) => Promise<QueryObserverResult<TvDetails | MovieDetails | undefined, Error>>;
}> = ({
isLoading,
result,
details,
refetch,
hasAdvancedRequest,
@@ -195,7 +193,7 @@ const JellyseerrSeasons: React.FC<{
return onAdvancedRequest?.(body)
}
requestMedia(result?.name!!, body, refetch);
requestMedia(details.name, body, refetch);
}
}, [jellyseerrApi, seasons, details, hasAdvancedRequest, onAdvancedRequest]);
@@ -227,7 +225,7 @@ const JellyseerrSeasons: React.FC<{
return onAdvancedRequest?.(body)
}
requestMedia(`${result?.name!!}, Season ${seasonNumber}`, body, refetch);
requestMedia(`${details.name}, Season ${seasonNumber}`, body, refetch);
}
}, [requestMedia, hasAdvancedRequest, onAdvancedRequest]);

View File

@@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import { ScreenOrientationEnum, useSettings, VideoPlayer } from "@/utils/atoms/settings";
import { BitrateSelector, BITRATES } from "@/components/BitrateSelector";
import {
BACKGROUND_FETCH_TASK,
@@ -7,9 +7,7 @@ import {
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
import { Ionicons } from "@expo/vector-icons";
const BackgroundFetch = !Platform.isTV
? require("expo-background-fetch")
: null;
const BackgroundFetch = !Platform.isTV ? require("expo-background-fetch") : null;
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
import { useRouter } from "expo-router";
@@ -22,6 +20,7 @@ import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import DisabledSetting from "@/components/settings/DisabledSetting";
import Dropdown from "@/components/common/Dropdown";
import { isNumber } from "lodash";
export const OtherSettings: React.FC = () => {
const router = useRouter();
@@ -84,10 +83,7 @@ export const OtherSettings: React.FC = () => {
return (
<DisabledSetting disabled={disabled}>
<ListGroup title={t("home.settings.other.other_title")} className="">
<ListItem
title={t("home.settings.other.auto_rotate")}
disabled={pluginSettings?.autoRotate?.locked}
>
<ListItem title={t("home.settings.other.auto_rotate")} disabled={pluginSettings?.autoRotate?.locked}>
<Switch
value={settings.autoRotate}
disabled={pluginSettings?.autoRotate?.locked}
@@ -97,17 +93,11 @@ export const OtherSettings: React.FC = () => {
<ListItem
title={t("home.settings.other.video_orientation")}
disabled={
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
>
<Dropdown
data={orientations}
disabled={
pluginSettings?.defaultVideoOrientation?.locked ||
settings.autoRotate
}
disabled={pluginSettings?.defaultVideoOrientation?.locked || settings.autoRotate}
keyExtractor={String}
titleExtractor={(item) => ScreenOrientationEnum[item]}
title={
@@ -115,17 +105,11 @@ export const OtherSettings: React.FC = () => {
<Text className="mr-1 text-[#8E8D91]">
{t(ScreenOrientationEnum[settings.defaultVideoOrientation])}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
</TouchableOpacity>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultVideoOrientation) =>
updateSettings({ defaultVideoOrientation })
}
onSelected={(defaultVideoOrientation) => updateSettings({ defaultVideoOrientation })}
/>
</ListItem>
@@ -136,27 +120,49 @@ export const OtherSettings: React.FC = () => {
<Switch
value={settings.safeAreaInControlsEnabled}
disabled={pluginSettings?.safeAreaInControlsEnabled?.locked}
onValueChange={(value) =>
updateSettings({ safeAreaInControlsEnabled: value })
}
onValueChange={(value) => updateSettings({ safeAreaInControlsEnabled: value })}
/>
</ListItem>
{/* {(Platform.OS === "ios" || Platform.isTVOS)&& (
<ListItem
title={t("home.settings.other.video_player")}
disabled={pluginSettings?.defaultPlayer?.locked}
>
<Dropdown
data={Object.values(VideoPlayer).filter(isNumber)}
disabled={pluginSettings?.defaultPlayer?.locked}
keyExtractor={String}
titleExtractor={(item) => t(`home.settings.other.video_players.${VideoPlayer[item]}`)}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{t(`home.settings.other.video_players.${VideoPlayer[settings.defaultPlayer]}`)}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
</TouchableOpacity>
}
label={t("home.settings.other.orientation")}
onSelected={(defaultPlayer) =>
updateSettings({ defaultPlayer })
}
/>
</ListItem>
)} */}
<ListItem
title={t("home.settings.other.show_custom_menu_links")}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onPress={() =>
Linking.openURL(
"https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links"
)
}
onPress={() => Linking.openURL("https://jellyfin.org/docs/general/clients/web-config/#custom-menu-links")}
>
<Switch
value={settings.showCustomMenuLinks}
disabled={pluginSettings?.showCustomMenuLinks?.locked}
onValueChange={(value) =>
updateSettings({ showCustomMenuLinks: value })
}
onValueChange={(value) => updateSettings({ showCustomMenuLinks: value })}
/>
</ListItem>
<ListItem
@@ -164,10 +170,7 @@ export const OtherSettings: React.FC = () => {
title={t("home.settings.other.hide_libraries")}
showArrow
/>
<ListItem
title={t("home.settings.other.default_quality")}
disabled={pluginSettings?.defaultBitrate?.locked}
>
<ListItem title={t("home.settings.other.default_quality")} disabled={pluginSettings?.defaultBitrate?.locked}>
<Dropdown
data={BITRATES}
disabled={pluginSettings?.defaultBitrate?.locked}
@@ -176,14 +179,8 @@ export const OtherSettings: React.FC = () => {
selected={settings.defaultBitrate}
title={
<TouchableOpacity className="flex flex-row items-center justify-between py-3 pl-3">
<Text className="mr-1 text-[#8E8D91]">
{settings.defaultBitrate?.key}
</Text>
<Ionicons
name="chevron-expand-sharp"
size={18}
color="#5A5960"
/>
<Text className="mr-1 text-[#8E8D91]">{settings.defaultBitrate?.key}</Text>
<Ionicons name="chevron-expand-sharp" size={18} color="#5A5960" />
</TouchableOpacity>
}
label={t("home.settings.other.default_quality")}
@@ -197,9 +194,7 @@ export const OtherSettings: React.FC = () => {
<Switch
value={settings.disableHapticFeedback}
disabled={pluginSettings?.disableHapticFeedback?.locked}
onValueChange={(disableHapticFeedback) =>
updateSettings({ disableHapticFeedback })
}
onValueChange={(disableHapticFeedback) => updateSettings({ disableHapticFeedback })}
/>
</ListItem>
</ListGroup>

View File

@@ -8,6 +8,7 @@ import { toast } from "sonner-native";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
import { useTranslation } from "react-i18next";
import {Colors} from "@/constants/Colors";
export const StorageSettings = () => {
const { deleteAllFiles, appSizeUsage } = useDownload();
@@ -61,7 +62,7 @@ export const StorageSettings = () => {
<View
style={{
width: `${(size.app / size.total) * 100}%`,
backgroundColor: "rgb(147 51 234)",
backgroundColor: Colors.primaryRGB,
}}
/>
<View
@@ -70,7 +71,7 @@ export const StorageSettings = () => {
((size.total - size.remaining - size.app) / size.total) *
100
}%`,
backgroundColor: "rgb(192 132 252)",
backgroundColor: Colors.primaryLightRGB,
}}
/>
</>

View File

@@ -1,65 +1,40 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { useAdjacentItems } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useHaptic } from "@/hooks/useHaptic";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import {
TrackInfo,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import {
getDefaultPlaySettings,
previousIndexes,
} from "@/utils/jellyfin/getDefaultPlaySettings";
import { getItemById } from "@/utils/jellyfin/user-library/getItemById";
import { writeToLog } from "@/utils/log";
import {
formatTimeString,
msToTicks,
secondsToMs,
ticksToMs,
ticksToSeconds,
} from "@/utils/time";
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router";
import {Text} from "@/components/common/Text";
import {Loader} from "@/components/Loader";
import {useAdjacentItems} from "@/hooks/useAdjacentEpisodes";
import {useCreditSkipper} from "@/hooks/useCreditSkipper";
import {useHaptic} from "@/hooks/useHaptic";
import {useIntroSkipper} from "@/hooks/useIntroSkipper";
import {useTrickplay} from "@/hooks/useTrickplay";
import {TrackInfo, VlcPlayerViewRef,} from "@/modules/VlcPlayer.types";
import {apiAtom} from "@/providers/JellyfinProvider";
import {useSettings, VideoPlayer} from "@/utils/atoms/settings";
import {getDefaultPlaySettings,} from "@/utils/jellyfin/getDefaultPlaySettings";
import {getItemById} from "@/utils/jellyfin/user-library/getItemById";
import {writeToLog} from "@/utils/log";
import {formatTimeString, msToTicks, secondsToMs, ticksToMs, ticksToSeconds,} from "@/utils/time";
import {Ionicons, MaterialIcons} from "@expo/vector-icons";
import {BaseItemDto, MediaSourceInfo,} from "@jellyfin/sdk/lib/generated-client";
import {Image} from "expo-image";
import {useLocalSearchParams, useRouter} from "expo-router";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { useAtom } from "jotai";
import { debounce } from "lodash";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Platform,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
SharedValue,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { VideoRef } from "react-native-video";
import {useAtom} from "jotai";
import {debounce} from "lodash";
import React, {useCallback, useEffect, useRef, useState} from "react";
import {Platform, TouchableOpacity, useWindowDimensions, View,} from "react-native";
import {Slider} from "react-native-awesome-slider";
import {runOnJS, SharedValue, useAnimatedReaction, useSharedValue,} from "react-native-reanimated";
import {useSafeAreaInsets} from "react-native-safe-area-context";
import {VideoRef} from "react-native-video";
import AudioSlider from "./AudioSlider";
import BrightnessSlider from "./BrightnessSlider";
import { ControlProvider } from "./contexts/ControlContext";
import { VideoProvider } from "./contexts/VideoContext";
import {ControlProvider} from "./contexts/ControlContext";
import {VideoProvider} from "./contexts/VideoContext";
import DropdownView from "./dropdown/DropdownView";
import { EpisodeList } from "./EpisodeList";
import {EpisodeList} from "./EpisodeList";
import NextEpisodeCountDownButton from "./NextEpisodeCountDownButton";
import SkipButton from "./SkipButton";
import { useControlsTimeout } from "./useControlsTimeout";
import { VideoTouchOverlay } from "./VideoTouchOverlay";
import {useControlsTimeout} from "./useControlsTimeout";
import {VideoTouchOverlay} from "./VideoTouchOverlay";
interface Props {
item: BaseItemDto;
@@ -494,7 +469,7 @@ export const Controls: React.FC<Props> = ({
)}
<View className="flex flex-row items-center space-x-2 ">
{!Platform.isTV && (
{!Platform.isTV && settings.defaultPlayer == VideoPlayer.VLC_4 && (
<TouchableOpacity
onPress={startPictureInPicture}
className="aspect-square flex flex-col rounded-xl items-center justify-center p-2"

View File

@@ -1,4 +1,3 @@
import { TrackInfo } from "@/modules/vlc-player";
import {
BaseItemDto,
MediaSourceInfo,

View File

@@ -1,4 +1,4 @@
import { TrackInfo } from "@/modules/vlc-player";
import { TrackInfo } from "@/modules/VlcPlayer.types";
import React, { createContext, useContext, useState, ReactNode, useEffect, useMemo } from "react";
import { useControlContext } from "./ControlContext";
import { Track } from "../types";

View File

@@ -1,7 +1,7 @@
import {
TrackInfo,
VlcPlayerViewRef,
} from "@/modules/vlc-player/src/VlcPlayer.types";
} from "@/modules/VlcPlayer.types";
import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

View File

@@ -1,5 +1,7 @@
export const Colors = {
primary: "#9334E9",
primaryRGB: "rgb(147 51 234)",
primaryLightRGB: "rgb(192 132 252)",
text: "#ECEDEE",
background: "#151718",
tint: "#fff",

View File

@@ -1,5 +1,5 @@
import axios, { AxiosError, AxiosInstance } from "axios";
import { Results } from "@/utils/jellyseerr/server/models/Search";
import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { storage } from "@/utils/mmkv";
import { inRange } from "lodash";
import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
@@ -14,7 +14,7 @@ import {
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {MediaRequestBody, RequestResultsResponse} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import {
SeasonWithEpisodes,
@@ -227,6 +227,23 @@ export class JellyseerrApi {
.then(({ data }) => data);
}
async getRequest(id: number): Promise<MediaRequest> {
return this.axios
?.get<MediaRequest>(Endpoints.API_V1 + Endpoints.REQUEST + `/${id}`)
.then(({ data }) => data);
}
async requests(params = {
filter: "all",
take: 10,
sort: "modified",
skip: 0
}): Promise<RequestResultsResponse> {
return this.axios
?.get<RequestResultsResponse>(Endpoints.API_V1 + Endpoints.REQUEST, {params})
.then(({data}) => data);
}
async movieDetails(id: number) {
return this.axios
?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`)
@@ -439,14 +456,34 @@ export const useJellyseerr = () => {
);
const isJellyseerrResult = (
items: any[] | null | undefined
): items is Results[] => {
items: any | null | undefined
): items is Results => {
return (
!items ||
(items.length >= 0 &&
Object.hasOwn(items[0], "mediaType") &&
Object.values(MediaType).includes(items[0]["mediaType"]))
);
items &&
Object.hasOwn(items, "mediaType") &&
Object.values(MediaType).includes(items["mediaType"])
)
};
const getTitle = (item: TvResult | TvDetails | MovieResult | MovieDetails) => {
return isJellyseerrResult(item)
? (item.mediaType == MediaType.MOVIE ? item?.originalTitle : item?.name)
: (item.mediaInfo.mediaType == MediaType.MOVIE ? (item as MovieDetails)?.title : (item as TvDetails)?.name)
};
const getYear = (item: TvResult | TvDetails | MovieResult | MovieDetails) => {
return new Date((
isJellyseerrResult(item)
? (item.mediaType == MediaType.MOVIE ? item?.releaseDate : item?.firstAirDate)
: (item.mediaInfo.mediaType == MediaType.MOVIE ? (item as MovieDetails)?.releaseDate : (item as TvDetails)?.firstAirDate))
|| ""
)?.getFullYear?.()
};
const getMediaType = (item: TvResult | TvDetails | MovieResult | MovieDetails): MediaType => {
return isJellyseerrResult(item)
? item.mediaType
: item?.mediaInfo?.mediaType
};
const jellyseerrRegion = useMemo(
@@ -464,6 +501,9 @@ export const useJellyseerr = () => {
setJellyseerrUser,
clearAllJellyseerData,
isJellyseerrResult,
getTitle,
getYear,
getMediaType,
jellyseerrRegion,
jellyseerrLocale,
requestMedia,

View File

@@ -6,16 +6,30 @@ import {
VlcPlayerViewRef,
VlcPlayerSource,
} from "./VlcPlayer.types";
import {useSettings, VideoPlayer} from "@/utils/atoms/settings";
import {Platform} from "react-native";
interface NativeViewRef extends VlcPlayerViewRef {
setNativeProps?: (props: Partial<VlcPlayerViewProps>) => void;
}
const NativeViewManager = requireNativeViewManager("VlcPlayer");
const VLCViewManager = requireNativeViewManager("VlcPlayer");
const VLC3ViewManager = requireNativeViewManager("VlcPlayer3");
// Create a forwarded ref version of the native view
const NativeView = React.forwardRef<NativeViewRef, VlcPlayerViewProps>(
(props, ref) => <NativeViewManager {...props} ref={ref} />
(props, ref) => {
const [settings] = useSettings();
if (Platform.OS === "ios" || Platform.isTVOS) {
if (settings.defaultPlayer == VideoPlayer.VLC_3) {
console.log("[Apple] Using Vlc Player 3")
return <VLC3ViewManager {...props} ref={ref}/>
}
}
console.log("Using default Vlc Player")
return <VLCViewManager {...props} ref={ref}/>
}
);
const VlcPlayerView = React.forwardRef<VlcPlayerViewRef, VlcPlayerViewProps>(

27
modules/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import VlcPlayerView from "./VlcPlayerView";
import {
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
VlcPlayerViewProps,
VlcPlayerViewRef,
} from "./VlcPlayer.types";
export {
VlcPlayerView,
VlcPlayerViewProps,
VlcPlayerViewRef,
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
};

View File

@@ -0,0 +1,6 @@
{
"platforms": ["ios", "tvos"],
"ios": {
"modules": ["VlcPlayer3Module"]
}
}

View File

@@ -0,0 +1,23 @@
Pod::Spec.new do |s|
s.name = 'VlcPlayer3'
s.version = '3.6.1b1'
s.summary = 'A sample project summary'
s.description = 'A sample project description'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.ios.dependency 'MobileVLCKit', s.version
s.tvos.dependency 'TVVLCKit', s.version
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -0,0 +1,71 @@
import ExpoModulesCore
public class VlcPlayer3Module: Module {
public func definition() -> ModuleDefinition {
Name("VlcPlayer3")
View(VlcPlayer3View.self) {
Prop("source") { (view: VlcPlayer3View, source: [String: Any]) in
view.setSource(source)
}
Prop("paused") { (view: VlcPlayer3View, paused: Bool) in
if paused {
view.pause()
} else {
view.play()
}
}
Events(
"onPlaybackStateChanged",
"onVideoStateChange",
"onVideoLoadStart",
"onVideoLoadEnd",
"onVideoProgress",
"onVideoError",
"onPipStarted"
)
AsyncFunction("startPictureInPicture") { (view: VlcPlayer3View) in
view.startPictureInPicture()
}
AsyncFunction("play") { (view: VlcPlayer3View) in
view.play()
}
AsyncFunction("pause") { (view: VlcPlayer3View) in
view.pause()
}
AsyncFunction("stop") { (view: VlcPlayer3View) in
view.stop()
}
AsyncFunction("seekTo") { (view: VlcPlayer3View, time: Int32) in
view.seekTo(time)
}
AsyncFunction("setAudioTrack") { (view: VlcPlayer3View, trackIndex: Int) in
view.setAudioTrack(trackIndex)
}
AsyncFunction("getAudioTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
return view.getAudioTracks()
}
AsyncFunction("setSubtitleTrack") { (view: VlcPlayer3View, trackIndex: Int) in
view.setSubtitleTrack(trackIndex)
}
AsyncFunction("getSubtitleTracks") { (view: VlcPlayer3View) -> [[String: Any]]? in
return view.getSubtitleTracks()
}
AsyncFunction("setSubtitleURL") {
(view: VlcPlayer3View, url: String, name: String) in
view.setSubtitleURL(url, name: name)
}
}
}
}

View File

@@ -0,0 +1,388 @@
import ExpoModulesCore
#if os(tvOS)
import TVVLCKit
#else
import MobileVLCKit
#endif
import UIKit
class VlcPlayer3View: ExpoView {
private var mediaPlayer: VLCMediaPlayer?
private var videoView: UIView?
private var progressUpdateInterval: TimeInterval = 1.0 // Update interval set to 1 second
private var isPaused: Bool = false
private var currentGeometryCString: [CChar]?
private var lastReportedState: VLCMediaPlayerState?
private var lastReportedIsPlaying: Bool?
private var customSubtitles: [(internalName: String, originalName: String)] = []
private var startPosition: Int32 = 0
private var isMediaReady: Bool = false
private var externalTrack: [String: String]?
private var progressTimer: DispatchSourceTimer?
private var isStopping: Bool = false // Define isStopping here
private var lastProgressCall = Date().timeIntervalSince1970
var hasSource = false
// MARK: - Initialization
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
setupNotifications()
}
// MARK: - Setup
private func setupView() {
DispatchQueue.main.async {
self.backgroundColor = .black
self.videoView = UIView()
self.videoView?.translatesAutoresizingMaskIntoConstraints = false
if let videoView = self.videoView {
self.addSubview(videoView)
NSLayoutConstraint.activate([
videoView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
videoView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
videoView.topAnchor.constraint(equalTo: self.topAnchor),
videoView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])
}
}
}
private func setupNotifications() {
NotificationCenter.default.addObserver(
self, selector: #selector(applicationWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil)
NotificationCenter.default.addObserver(
self, selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification, object: nil)
}
// MARK: - Public Methods
func startPictureInPicture() { }
@objc func play() {
self.mediaPlayer?.play()
self.isPaused = false
print("Play")
}
@objc func pause() {
self.mediaPlayer?.pause()
self.isPaused = true
}
@objc func seekTo(_ time: Int32) {
guard let player = self.mediaPlayer else { return }
let wasPlaying = player.isPlaying
if wasPlaying {
self.pause()
}
if let duration = player.media?.length.intValue {
print("Seeking to time: \(time) Video Duration \(duration)")
// If the specified time is greater than the duration, seek to the end
let seekTime = time > duration ? duration - 1000 : time
player.time = VLCTime(int: seekTime)
if wasPlaying {
self.play()
}
self.updatePlayerState()
} else {
print("Error: Unable to retrieve video duration")
}
}
@objc func setSource(_ source: [String: Any]) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if self.hasSource {
return
}
let mediaOptions = source["mediaOptions"] as? [String: Any] ?? [:]
self.externalTrack = source["externalTrack"] as? [String: String]
var initOptions = source["initOptions"] as? [Any] ?? []
self.startPosition = source["startPosition"] as? Int32 ?? 0
initOptions.append("--start-time=\(self.startPosition)")
guard let uri = source["uri"] as? String, !uri.isEmpty else {
print("Error: Invalid or empty URI")
self.onVideoError?(["error": "Invalid or empty URI"])
return
}
let autoplay = source["autoplay"] as? Bool ?? false
let isNetwork = source["isNetwork"] as? Bool ?? false
self.onVideoLoadStart?(["target": self.reactTag ?? NSNull()])
self.mediaPlayer = VLCMediaPlayer(options: initOptions)
self.mediaPlayer?.delegate = self
self.mediaPlayer?.drawable = self.videoView
self.mediaPlayer?.scaleFactor = 0
let media: VLCMedia
if isNetwork {
print("Loading network file: \(uri)")
media = VLCMedia(url: URL(string: uri)!)
} else {
print("Loading local file: \(uri)")
if uri.starts(with: "file://"), let url = URL(string: uri) {
media = VLCMedia(url: url)
} else {
media = VLCMedia(path: uri)
}
}
print("Debug: Media options: \(mediaOptions)")
media.addOptions(mediaOptions)
self.mediaPlayer?.media = media
self.hasSource = true
if autoplay {
print("Playing...")
self.play()
}
}
}
@objc func setAudioTrack(_ trackIndex: Int) {
self.mediaPlayer?.currentAudioTrackIndex = Int32(trackIndex)
}
@objc func getAudioTracks() -> [[String: Any]]? {
guard let trackNames = mediaPlayer?.audioTrackNames,
let trackIndexes = mediaPlayer?.audioTrackIndexes
else {
return nil
}
return zip(trackNames, trackIndexes).map { name, index in
return ["name": name, "index": index]
}
}
@objc func setSubtitleTrack(_ trackIndex: Int) {
print("Debug: Attempting to set subtitle track to index: \(trackIndex)")
self.mediaPlayer?.currentVideoSubTitleIndex = Int32(trackIndex)
print(
"Debug: Current subtitle track index after setting: \(self.mediaPlayer?.currentVideoSubTitleIndex ?? -1)"
)
}
@objc func setSubtitleURL(_ subtitleURL: String, name: String) {
guard let url = URL(string: subtitleURL) else {
print("Error: Invalid subtitle URL")
return
}
let result = self.mediaPlayer?.addPlaybackSlave(url, type: .subtitle, enforce: true)
if let result = result {
let internalName = "Track \(self.customSubtitles.count + 1)"
print("Subtitle added with result: \(result) \(internalName)")
self.customSubtitles.append((internalName: internalName, originalName: name))
} else {
print("Failed to add subtitle")
}
}
@objc func getSubtitleTracks() -> [[String: Any]]? {
guard let mediaPlayer = self.mediaPlayer else {
return nil
}
let count = mediaPlayer.numberOfSubtitlesTracks
print("Debug: Number of subtitle tracks: \(count)")
guard count > 0 else {
return nil
}
var tracks: [[String: Any]] = []
if let names = mediaPlayer.videoSubTitlesNames as? [String],
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber]
{
for (index, name) in zip(indexes, names) {
if let customSubtitle = customSubtitles.first(where: { $0.internalName == name }) {
tracks.append(["name": customSubtitle.originalName, "index": index.intValue])
} else {
tracks.append(["name": name, "index": index.intValue])
}
}
}
print("Debug: Subtitle tracks: \(tracks)")
return tracks
}
@objc func stop(completion: (() -> Void)? = nil) {
guard !isStopping else {
completion?()
return
}
isStopping = true
// If we're not on the main thread, dispatch to main thread
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.performStop(completion: completion)
}
} else {
performStop(completion: completion)
}
}
// MARK: - Private Methods
@objc private func applicationWillResignActive() {
}
@objc private func applicationDidBecomeActive() {
}
private func performStop(completion: (() -> Void)? = nil) {
// Stop the media player
mediaPlayer?.stop()
// Remove observer
NotificationCenter.default.removeObserver(self)
// Clear the video view
videoView?.removeFromSuperview()
videoView = nil
// Release the media player
mediaPlayer?.delegate = nil
mediaPlayer = nil
isStopping = false
completion?()
}
private func updateVideoProgress() {
guard let player = self.mediaPlayer else { return }
let currentTimeMs = player.time.intValue
let durationMs = player.media?.length.intValue ?? 0
print("Debug: Current time: \(currentTimeMs)")
if currentTimeMs >= 0 && currentTimeMs < durationMs {
if player.isPlaying && !self.isMediaReady {
self.isMediaReady = true
// Set external track subtitle when starting.
if let externalTrack = self.externalTrack {
if let name = externalTrack["name"], !name.isEmpty {
let deliveryUrl = externalTrack["DeliveryUrl"] ?? ""
self.setSubtitleURL(deliveryUrl, name: name)
}
}
}
self.onVideoProgress?([
"currentTime": currentTimeMs,
"duration": durationMs,
])
}
}
// MARK: - Expo Events
@objc var onPlaybackStateChanged: RCTDirectEventBlock?
@objc var onVideoLoadStart: RCTDirectEventBlock?
@objc var onVideoStateChange: RCTDirectEventBlock?
@objc var onVideoProgress: RCTDirectEventBlock?
@objc var onVideoLoadEnd: RCTDirectEventBlock?
@objc var onVideoError: RCTDirectEventBlock?
@objc var onPipStarted: RCTDirectEventBlock?
// MARK: - Deinitialization
deinit {
performStop()
}
}
extension VlcPlayer3View: VLCMediaPlayerDelegate {
func mediaPlayerTimeChanged(_ aNotification: Notification) {
// self?.updateVideoProgress()
let timeNow = Date().timeIntervalSince1970
if timeNow - lastProgressCall >= 1 {
lastProgressCall = timeNow
updateVideoProgress()
}
}
func mediaPlayerStateChanged(_ aNotification: Notification) {
self.updatePlayerState()
}
private func updatePlayerState() {
guard let player = self.mediaPlayer else { return }
let currentState = player.state
var stateInfo: [String: Any] = [
"target": self.reactTag ?? NSNull(),
"currentTime": player.time.intValue,
"duration": player.media?.length.intValue ?? 0,
"error": false,
]
if player.isPlaying {
stateInfo["isPlaying"] = true
stateInfo["isBuffering"] = false
stateInfo["state"] = "Playing"
} else {
stateInfo["isPlaying"] = false
stateInfo["state"] = "Paused"
}
if player.state == VLCMediaPlayerState.buffering {
stateInfo["isBuffering"] = true
stateInfo["state"] = "Buffering"
} else if player.state == VLCMediaPlayerState.error {
print("player.state ~ error")
stateInfo["state"] = "Error"
self.onVideoLoadEnd?(stateInfo)
} else if player.state == VLCMediaPlayerState.opening {
print("player.state ~ opening")
stateInfo["state"] = "Opening"
}
if self.lastReportedState != currentState
|| self.lastReportedIsPlaying != player.isPlaying
{
self.lastReportedState = currentState
self.lastReportedIsPlaying = player.isPlaying
self.onVideoStateChange?(stateInfo)
}
}
}
extension VlcPlayer3View: VLCMediaDelegate {
// Implement VLCMediaDelegate methods if needed
}
extension VLCMediaPlayerState {
var description: String {
switch self {
case .opening: return "Opening"
case .buffering: return "Buffering"
case .playing: return "Playing"
case .paused: return "Paused"
case .stopped: return "Stopped"
case .ended: return "Ended"
case .error: return "Error"
case .esAdded: return "ESAdded"
@unknown default: return "Unknown"
}
}
}

View File

@@ -0,0 +1,5 @@
import { requireNativeModule } from 'expo-modules-core';
// It loads the native module object from the JSI or falls back to
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
export default requireNativeModule('VlcPlayer3');

View File

@@ -1,2 +0,0 @@
#Sun Nov 17 18:25:45 AEDT 2024
gradle.version=8.9

View File

@@ -1,68 +0,0 @@
import {
EventEmitter,
EventSubscription,
} from "expo-modules-core";
import VlcPlayerModule from "./src/VlcPlayerModule";
import VlcPlayerView from "./src/VlcPlayerView";
import {
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
VlcPlayerViewProps,
VlcPlayerViewRef,
} from "./src/VlcPlayer.types";
const emitter = new EventEmitter(VlcPlayerModule);
export function addPlaybackStateListener(
listener: (event: PlaybackStatePayload) => void
): EventSubscription {
return emitter.addListener<PlaybackStatePayload>(
"onPlaybackStateChanged",
listener
);
}
export function addVideoLoadStartListener(
listener: (event: VideoLoadStartPayload) => void
): EventSubscription {
return emitter.addListener<VideoLoadStartPayload>(
"onVideoLoadStart",
listener
);
}
export function addVideoStateChangeListener(
listener: (event: VideoStateChangePayload) => void
): EventSubscription {
return emitter.addListener<VideoStateChangePayload>(
"onVideoStateChange",
listener
);
}
export function addVideoProgressListener(
listener: (event: VideoProgressPayload) => void
): EventSubscription {
return emitter.addListener<VideoProgressPayload>("onVideoProgress", listener);
}
export {
VlcPlayerView,
VlcPlayerViewProps,
VlcPlayerViewRef,
PlaybackStatePayload,
ProgressUpdatePayload,
VideoLoadStartPayload,
VideoStateChangePayload,
VideoProgressPayload,
VlcPlayerSource,
TrackInfo,
ChapterInfo,
};

View File

@@ -19,6 +19,5 @@ Pod::Spec.new do |s|
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
s.source_files = "*.{h,m,mm,swift,hpp,cpp}"
end

View File

@@ -3,7 +3,6 @@ import UIKit
import VLCKit
import os
public class VLCPlayerView: UIView {
func setupView(parent: UIView) {
self.backgroundColor = .black

View File

@@ -128,7 +128,12 @@
"OTHER": "Andere",
"UNKNOWN": "Unbekannt"
},
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
"safe_area_in_controls": "Sicherer Bereich in den Steuerungen",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Benutzerdefinierte Menülinks anzeigen",
"hide_libraries": "Bibliotheken ausblenden",
"select_liraries_you_want_to_hide": "Wähl die Bibliotheken aus, die du im Bibliothekstab und auf der Startseite ausblenden möchtest.",
@@ -168,7 +173,8 @@
"tv_quota_limit": "TV-Anfragelimit",
"tv_quota_days": "TV-Anfragetage",
"reset_jellyseerr_config_button": "Setze Jellyseerr-Konfiguration zurück",
"unlimited": "Unlimitiert"
"unlimited": "Unlimitiert",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "Aktiviere Marlin Search",
@@ -433,7 +439,7 @@
"tags": "Tags",
"quality_profile": "Qualitätsprofil",
"root_folder": "Root-Ordner",
"season_x": "Staffel {{seasons}}",
"season_all": "Season (all)",
"season_number": "Staffel {{season_number}}",
"number_episodes": "{{episode_number}} Episodes",
"born": "Geboren",

View File

@@ -129,11 +129,16 @@
"UNKNOWN": "Unknown"
},
"safe_area_in_controls": "Safe area in controls",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Show Custom Menu Links",
"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.",
"disable_haptic_feedback": "Disable Haptic Feedback",
"default_quality": "Default quality"
"default_quality": "Default quality",
},
"downloads": {
"downloads_title": "Downloads",
@@ -168,7 +173,8 @@
"tv_quota_limit": "TV quota limit",
"tv_quota_days": "TV quota days",
"reset_jellyseerr_config_button": "Reset Jellyseerr config",
"unlimited": "Unlimited"
"unlimited": "Unlimited",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "Enable Marlin Search ",
@@ -437,7 +443,7 @@
"tags": "Tags",
"quality_profile": "Quality Profile",
"root_folder": "Root Folder",
"season_x": "Season {{seasons}}",
"season_all": "Season (all)",
"season_number": "Season {{season_number}}",
"number_episodes": "{{episode_number}} Episodes",
"born": "Born",

View File

@@ -128,7 +128,12 @@
"OTHER": "Otra",
"UNKNOWN": "Desconocida"
},
"safe_area_in_controls": "Área segura en controles",
"safe_area_in_controls": "Área segura en controles",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Mostrar enlaces de menú personalizados",
"hide_libraries": "Ocultar bibliotecas",
"select_liraries_you_want_to_hide": "Selecciona las bibliotecas que quieres ocultar de la pestaña Bibliotecas y de Inicio.",
@@ -168,7 +173,8 @@
"tv_quota_limit": "Límite de cuota de series",
"tv_quota_days": "Días de cuota de series",
"reset_jellyseerr_config_button": "Restablecer configuración de Jellyseerr",
"unlimited": "Ilimitado"
"unlimited": "Ilimitado",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "Habilitar búsqueda de Marlin",
@@ -433,7 +439,7 @@
"tags": "Etiquetas",
"quality_profile": "Perfil de calidad",
"root_folder": "Carpeta raíz",
"season_x": "Temporada {{seasons}}",
"season_all": "Season (all)",
"season_number": "Temporada {{season_number}}",
"number_episodes": "{{episode_number}} episodios",
"born": "Nacido",

View File

@@ -128,7 +128,12 @@
"OTHER": "Autre",
"UNKNOWN": "Inconnu"
},
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
"safe_area_in_controls": "Zone de sécurité dans les contrôles",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Afficher les liens personnalisés",
"hide_libraries": "Cacher des bibliothèques",
"select_liraries_you_want_to_hide": "Sélectionnez les bibliothèques que vous souhaitez masquer dans longlet Bibliothèque et les sections de la page daccueil.",
@@ -169,7 +174,8 @@
"tv_quota_limit": "Limite de quota TV",
"tv_quota_days": "Jours de quota TV",
"reset_jellyseerr_config_button": "Réinitialiser la configuration Jellyseerr",
"unlimited": "Illimité"
"unlimited": "Illimité",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "Activer Marlin Search ",
@@ -434,7 +440,7 @@
"tags": "Tags",
"quality_profile": "Profil de qualité",
"root_folder": "Dossier racine",
"season_x": "Saison {{seasons}}",
"season_all": "Season (all)",
"season_number": "Saison {{season_number}}",
"number_episodes": "{{episode_number}} épisodes",
"born": "Né(e) le",

View File

@@ -128,7 +128,12 @@
"OTHER": "Altro",
"UNKNOWN": "Sconosciuto"
},
"safe_area_in_controls": "Area sicura per i controlli",
"safe_area_in_controls": "Area sicura per i controlli",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Mostra i link del menu personalizzato",
"hide_libraries": "Nascondi Librerie",
"select_liraries_you_want_to_hide": "Selezionate le librerie che volete nascondere dalla scheda Libreria e dalle sezioni della pagina iniziale.",
@@ -168,7 +173,8 @@
"tv_quota_limit": "Limite di quota per le serie TV",
"tv_quota_days": "Giorni di quota per le serie TV",
"reset_jellyseerr_config_button": "Ripristina la configurazione di Jellyseerr",
"unlimited": "Illimitato"
"unlimited": "Illimitato",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "Abilita la ricerca Marlin ",
@@ -433,7 +439,7 @@
"tags": "Tag",
"quality_profile": "Profilo qualità",
"root_folder": "Cartella radice",
"season_x": "Stagione {{seasons}}",
"season_all": "Season (all)",
"season_number": "Stagione {{season_number}}",
"number_episodes": "{{episode_number}} Episodio",
"born": "Nato",

View File

@@ -128,7 +128,12 @@
"OTHER": "その他",
"UNKNOWN": "不明"
},
"safe_area_in_controls": "コントロールの安全エリア",
"safe_area_in_controls": "コントロールの安全エリア",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "カスタムメニューのリンクを表示",
"hide_libraries": "ライブラリを非表示",
"select_liraries_you_want_to_hide": "ライブラリタブとホームページセクションから非表示にするライブラリを選択します。",
@@ -167,7 +172,8 @@
"tv_quota_limit": "テレビのクオータ制限",
"tv_quota_days": "テレビのクオータ日数",
"reset_jellyseerr_config_button": "Jellyseerrの設定をリセット",
"unlimited": "無制限"
"unlimited": "無制限",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "マーリン検索を有効にする ",
@@ -432,7 +438,7 @@
"tags": "タグ",
"quality_profile": "画質プロファイル",
"root_folder": "ルートフォルダ",
"season_x": "シーズン{{seasons}}",
"season_all": "Season (all)",
"season_number": "シーズン{{season_number}}",
"number_episodes": "エピソード{{episode_number}}",
"born": "生まれ",

View File

@@ -128,7 +128,12 @@
"OTHER": "Andere",
"UNKNOWN": "Onbekend"
},
"safe_area_in_controls": "Veilig gebied in bedieningen",
"safe_area_in_controls": "Veilig gebied in bedieningen",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Aangepaste menulinks tonen",
"hide_libraries": "Verberg Bibliotheken",
"select_liraries_you_want_to_hide": "Selecteer de bibliotheken die je wil verbergen van de Bibliotheektab en hoofdpagina onderdelen.",
@@ -168,7 +173,8 @@
"tv_quota_limit": "Limiet serie quota",
"tv_quota_days": "Serie Quota dagen",
"reset_jellyseerr_config_button": "Jellyseerr opnieuw instellen",
"unlimited": "Ongelimiteerd"
"unlimited": "Ongelimiteerd",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "Marlin Search inschakelen ",
@@ -433,7 +439,7 @@
"tags": "Labels",
"quality_profile": "Kwaliteitsprofiel",
"root_folder": "Hoofdmap",
"season_x": "Seizoen {{seasons}}",
"season_all": "Season (all)",
"season_number": "Seizoen {{season_number}}",
"number_episodes": "{{episode_number}} Afleveringen",
"born": "Geboren",

View File

@@ -128,7 +128,12 @@
"OTHER": "Diğer",
"UNKNOWN": "Bilinmeyen"
},
"safe_area_in_controls": "Kontrollerde Güvenli Alan",
"safe_area_in_controls": "Kontrollerde Güvenli Alan",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "Özel Menü Bağlantılarını Göster",
"hide_libraries": "Kütüphaneleri Gizle",
"select_liraries_you_want_to_hide": "Kütüphane sekmesinden ve ana sayfa bölümlerinden gizlemek istediğiniz kütüphaneleri seçin.",
@@ -167,7 +172,8 @@
"tv_quota_limit": "TV kota limiti",
"tv_quota_days": "TV kota günleri",
"reset_jellyseerr_config_button": "Jellyseerr yapılandırmasını sıfırla",
"unlimited": "Sınırsız"
"unlimited": "Sınırsız",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "Marlin Aramasını Etkinleştir ",
@@ -432,7 +438,7 @@
"tags": "Etiketler",
"quality_profile": "Kalite Profili",
"root_folder": "Kök Klasör",
"season_x": "Sezon {{seasons}}",
"season_all": "Season (all)",
"season_number": "Sezon {{season_number}}",
"number_episodes": "Bölüm {{episode_number}}",
"born": "Doğum",

View File

@@ -128,7 +128,12 @@
"OTHER": "其他",
"UNKNOWN": "未知"
},
"safe_area_in_controls": "控制中的安全区域",
"safe_area_in_controls": "控制中的安全区域",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "显示自定义菜单链接",
"hide_libraries": "隐藏媒体库",
"select_liraries_you_want_to_hide": "选择您想从媒体库页面和主页隐藏的媒体库。",
@@ -167,7 +172,8 @@
"tv_quota_limit": "剧集配额限制",
"tv_quota_days": "剧集配额天数",
"reset_jellyseerr_config_button": "重置 Jellyseerr 设置",
"unlimited": "无限制"
"unlimited": "无限制",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "启用 Marlin 搜索",
@@ -432,7 +438,7 @@
"tags": "标签",
"quality_profile": "质量配置文件",
"root_folder": "根文件夹",
"season_x": "第 {{seasons}} 季",
"season_all": "Season (all)",
"season_number": "第 {{season_number}} 季",
"number_episodes": "{{episode_number}} 集",
"born": "出生",

View File

@@ -128,7 +128,12 @@
"OTHER": "其他",
"UNKNOWN": "未知"
},
"safe_area_in_controls": "控制中的安全區域",
"safe_area_in_controls": "控制中的安全區域",
"video_player": "Video player",
"video_players": {
"VLC_3": "VLC 3",
"VLC_4": "VLC 4 (Experimental + PiP)"
},
"show_custom_menu_links": "顯示自定義菜單鏈接",
"hide_libraries": "隱藏媒體庫",
"select_liraries_you_want_to_hide": "選擇您想從媒體庫頁面和主頁隱藏的媒體庫。",
@@ -167,7 +172,8 @@
"tv_quota_limit": "電視配額限制",
"tv_quota_days": "電視配額天數",
"reset_jellyseerr_config_button": "重置 Jellyseerr 配置",
"unlimited": "無限制"
"unlimited": "無限制",
"plus_n_more": "+{{n}} more"
},
"marlin_search": {
"enable_marlin_search": "啟用 Marlin 搜索",
@@ -432,7 +438,7 @@
"tags": "標籤",
"quality_profile": "質量配置文件",
"root_folder": "根文件夾",
"season_x": "第 {{seasons}} 季",
"season_all": "Season (all)",
"season_number": "第 {{season_number}} 季",
"number_episodes": "{{episode_number}} 集",
"born": "出生",

View File

@@ -14,6 +14,7 @@ import {
import { Bitrate, BITRATES } from "@/components/BitrateSelector";
import { apiAtom } from "@/providers/JellyfinProvider";
import { writeInfoLog } from "@/utils/log";
import {Video} from "@/utils/jellyseerr/server/models/Movie";
const STREAMYFIN_PLUGIN_ID = "1e9e5d386e6746158719e98a5c34f004";
const STREAMYFIN_PLUGIN_SETTINGS = "STREAMYFIN_PLUGIN_SETTINGS";
@@ -112,6 +113,12 @@ export type HomeSectionNextUpResolver = {
enableRewatching?: boolean;
};
export enum VideoPlayer {
// NATIVE, //todo: changes will make this a lot more easier to implement if we want. delete if not wanted
VLC_3,
VLC_4
}
export type Settings = {
home?: Home | null;
autoRotate?: boolean;
@@ -146,6 +153,7 @@ export type Settings = {
jellyseerrServerUrl?: string;
hiddenLibraries?: string[];
enableH265ForChromecast: boolean;
defaultPlayer: VideoPlayer;
};
export interface Lockable<T> {
@@ -200,6 +208,7 @@ const defaultValues: Settings = {
jellyseerrServerUrl: undefined,
hiddenLibraries: [],
enableH265ForChromecast: false,
defaultPlayer: VideoPlayer.VLC_3, // ios only setting. does not matter what this is for android
};
const loadSettings = (): Partial<Settings> => {