Merge branch 'master' into feat/i18n

This commit is contained in:
Simon Caron
2024-12-31 12:24:28 -05:00
33 changed files with 1223 additions and 703 deletions

View File

@@ -1,40 +1,62 @@
import React, {useCallback, useRef, useState} from "react";
import {useLocalSearchParams} from "expo-router";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {Text} from "@/components/common/Text";
import {ParallaxScrollView} from "@/components/ParallaxPage";
import {Image} from "expo-image";
import {TouchableOpacity, View} from "react-native";
import {Ionicons} from "@expo/vector-icons";
import {useSafeAreaInsets} from "react-native-safe-area-context";
import {OverviewText} from "@/components/OverviewText";
import {GenreTags} from "@/components/GenreTags";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import {useQuery} from "@tanstack/react-query";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {Button} from "@/components/Button";
import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet";
import {IssueType, IssueTypeName} from "@/utils/jellyseerr/server/constants/issue";
import React, { useCallback, useRef, useState } from "react";
import { useLocalSearchParams } from "expo-router";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { Text } from "@/components/common/Text";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Image } from "expo-image";
import { TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OverviewText } from "@/components/OverviewText";
import { GenreTags } from "@/components/GenreTags";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Button } from "@/components/Button";
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetModal,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import {
IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import * as DropdownMenu from "zeego/dropdown-menu";
import {Input} from "@/components/common/Input";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import { Input } from "@/components/common/Input";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import {JellyserrRatings} from "@/components/Ratings";
import { JellyserrRatings } from "@/components/Ratings";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
const params = useLocalSearchParams();
const {mediaTitle, releaseYear, canRequest: canRequestString, posterSrc, ...result} =
params as unknown as {mediaTitle: string, releaseYear: number, canRequest: string, posterSrc: string} & Partial<MovieResult | TvResult>;
const {
mediaTitle,
releaseYear,
canRequest: canRequestString,
posterSrc,
...result
} = params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
const canRequest = canRequestString === "true";
const {jellyseerrApi, requestMedia} = useJellyseerr();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [issueType, setIssueType] = useState<IssueType>();
const [issueMessage, setIssueMessage] = useState<string>();
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const {data: details, isLoading} = useQuery({
const {
data: details,
isFetching,
isLoading,
} = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
staleTime: 0,
@@ -45,8 +67,8 @@ const Page: React.FC = () => {
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieDetails(result.id!!)
: jellyseerrApi?.tvDetails(result.id!!)
}
: jellyseerrApi?.tvDetails(result.id!!);
},
});
const renderBackdrop = useCallback(
@@ -62,23 +84,30 @@ const Page: React.FC = () => {
const submitIssue = useCallback(() => {
if (result.id && issueType && issueMessage && details) {
jellyseerrApi?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
jellyseerrApi
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
.then(() => {
setIssueType(undefined)
setIssueMessage(undefined)
bottomSheetModalRef?.current?.close()
})
setIssueType(undefined);
setIssueMessage(undefined);
bottomSheetModalRef?.current?.close();
});
}
}, [jellyseerrApi, details, result, issueType, issueMessage])
}, [jellyseerrApi, details, result, issueType, issueMessage]);
const request = useCallback(() => requestMedia(mediaTitle, {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons.filter(s => s.seasonNumber !== 0).map(s => s.seasonNumber)
}), [details, result, requestMedia]);
const request = useCallback(
() =>
requestMedia(mediaTitle, {
mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
}),
[details, result, requestMedia]
);
return (
return (
<View
className="flex-1 relative"
style={{
@@ -115,7 +144,7 @@ const Page: React.FC = () => {
name="image-outline"
size={24}
color="white"
style={{opacity: 0.4}}
style={{ opacity: 0.4 }}
/>
</View>
)}
@@ -123,12 +152,18 @@ const Page: React.FC = () => {
}
>
<View className="flex flex-col">
<View className="p-4 space-y-4">
<>
<View className="space-y-4">
<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)}/>
<Text uiTextView selectable className="font-bold text-2xl mb-1">{mediaTitle}</Text>
<JellyserrRatings result={result as MovieResult | TvResult} />
<Text
uiTextView
selectable
className="font-bold text-2xl mb-1"
>
{mediaTitle}
</Text>
<Text className="opacity-50">{releaseYear}</Text>
</View>
<Image
@@ -140,34 +175,39 @@ const Page: React.FC = () => {
}}
/>
</View>
</>
<GenreTags genres={details?.genres?.map(g => g.name) || []} />
{canRequest ?
<Button color="purple" onPress={request}>Request</Button>
:
<Button
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
color="transparent"
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name="warning-outline" size={24} color="white"/>
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}>
Report issue
</Button>
}
<OverviewText text={result.overview} className="mb-4" />
<View className="mb-4">
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
</View>
{canRequest ? (
<Button color="purple" onPress={request}>
Request
</Button>
) : (
<Button
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
color="transparent"
onPress={() => bottomSheetModalRef?.current?.present()}
iconLeft={
<Ionicons name="warning-outline" size={24} color="white" />
}
style={{
borderWidth: 1,
borderStyle: "solid",
}}
>
Report issue
</Button>
)}
<OverviewText text={result.overview} className="mt-4" />
</View>
{result.mediaType === MediaType.TV &&
<JellyseerrSeasons
isLoading={isLoading}
result={result as TvResult}
details={details as TvDetails}
/>
}
{result.mediaType === MediaType.TV && (
<JellyseerrSeasons
isLoading={isLoading || isFetching}
result={result as TvResult}
details={details as TvDetails}
/>
)}
</View>
</View>
</ParallaxScrollView>
@@ -185,17 +225,23 @@ const Page: React.FC = () => {
<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">Whats wrong?</Text>
<Text className="font-bold text-2xl text-neutral-100">
Whats wrong?
</Text>
</View>
<View className="flex flex-col space-y-2 items-start">
<View className="flex flex-col">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col">
<Text className="opacity-50 mb-1 text-xs">Issue Type</Text>
<Text className="opacity-50 mb-1 text-xs">
Issue Type
</Text>
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between">
<Text style={{}} className="" numberOfLines={1}>
{issueType ? IssueTypeName[issueType] : 'Select an issue' }
{issueType
? IssueTypeName[issueType]
: "Select an issue"}
</Text>
</TouchableOpacity>
</View>
@@ -210,14 +256,20 @@ const Page: React.FC = () => {
sideOffset={0}
>
<DropdownMenu.Label>Types</DropdownMenu.Label>
{Object.entries(IssueTypeName).reverse().map(([key, value], idx) => (
<DropdownMenu.Item
key={value}
onSelect={() => setIssueType(key as unknown as IssueType)}
>
<DropdownMenu.ItemTitle>{value}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
@@ -234,11 +286,7 @@ const Page: React.FC = () => {
onChangeText={setIssueMessage}
/>
</View>
<Button
className="mt-auto"
onPress={submitIssue}
color="purple"
>
<Button className="mt-auto" onPress={submitIssue} color="purple">
Submit
</Button>
</View>
@@ -246,6 +294,6 @@ const Page: React.FC = () => {
</BottomSheetModal>
</View>
);
}
};
export default Page;
export default Page;

View File

@@ -1,8 +1,11 @@
import { Text } from "@/components/common/Text";
import { DownloadItems } from "@/components/DownloadItem";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { Ratings } from "@/components/Ratings";
import { NextUp } from "@/components/series/NextUp";
import { SeasonPicker } from "@/components/series/SeasonPicker";
import { SeriesActions } from "@/components/series/SeriesActions";
import { SeriesHeader } from "@/components/series/SeriesHeader";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
@@ -70,6 +73,7 @@ const page: React.FC = () => {
});
return res?.data.Items || [];
},
staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id,
});
@@ -133,10 +137,7 @@ const page: React.FC = () => {
}
>
<View className="flex flex-col pt-4">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>
</View>
<SeriesHeader item={item} />
<View className="mb-4">
<NextUp seriesId={seriesId} />
</View>

View File

@@ -144,8 +144,6 @@ const Page = () => {
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null;
console.log("[libraryId] ~", library);
let itemType: BaseItemKind | undefined;
// This fix makes sure to only return 1 type of items, if defined.

View File

@@ -30,16 +30,16 @@ import React, {
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {Tag} from "@/components/GenreTags";
import { Tag } from "@/components/GenreTags";
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
import {sortBy} from "lodash";
import { useTranslation } from "react-i18next";
import {sortBy} from "lodash";
type SearchType = 'Library' | 'Discover';
type SearchType = "Library" | "Discover";
const exampleSearches = [
"Lord of the rings",
@@ -155,29 +155,41 @@ export default function search() {
const response = await jellyseerrApi?.search({
query: new URLSearchParams(debouncedSearch).toString(),
page: 1, // todo: maybe rework page & page-size if first results are not enough...
language: 'en'
})
language: "en",
});
return response?.results;
},
enabled: !!jellyseerrApi && searchType === "Discover" && debouncedSearch.length > 0,
enabled:
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length > 0,
});
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled: !!jellyseerrApi && searchType === "Discover" && debouncedSearch.length == 0,
enabled:
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length == 0,
});
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(() =>
jellyseerrResults?.filter(r => r.mediaType === MediaType.MOVIE) as MovieResult[],
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE
) as MovieResult[],
[jellyseerrResults]
)
);
const jellyseerrTvResults: TvResult[] | undefined = useMemo(() =>
jellyseerrResults?.filter(r => r.mediaType === MediaType.TV) as TvResult[],
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV
) as TvResult[],
[jellyseerrResults]
)
);
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
@@ -262,7 +274,17 @@ export default function search() {
jellyseerrMovieResults?.length ||
jellyseerrTvResults?.length
);
}, [artists, episodes, albums, songs, movies, series, collections, actors, jellyseerrResults]);
}, [
artists,
episodes,
albums,
songs,
movies,
series,
collections,
actors,
jellyseerrResults,
]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
@@ -292,14 +314,24 @@ export default function search() {
</View>
)}
{jellyseerrApi && (
<View className="flex flex-row flex-wrap space-x-2 px-4">
<TouchableOpacity onPress={() => setSearchType('Library')}>
<Tag text="Library" textClass="p-1"
className={searchType === "Library" ? "bg-neutral-600" : undefined}/>
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
<TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag
text="Library"
textClass="p-1"
className={
searchType === "Library" ? "bg-neutral-600" : undefined
}
/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType('Discover')}>
<Tag text="Discover" textClass="p-1"
className={searchType === "Discover" ? "bg-neutral-600" : undefined}/>
<TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag
text="Discover"
textClass="p-1"
className={
searchType === "Discover" ? "bg-neutral-600" : undefined
}
/>
</TouchableOpacity>
</View>
)}
@@ -321,7 +353,7 @@ export default function search() {
className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id}/>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
@@ -340,7 +372,7 @@ export default function search() {
item={item}
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id}/>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
@@ -359,8 +391,8 @@ export default function search() {
key={item.Id}
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item}/>
<ItemCardText item={item}/>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
@@ -373,7 +405,7 @@ export default function search() {
item={item}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id}/>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
@@ -389,8 +421,8 @@ export default function search() {
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item}/>
<ItemCardText item={item}/>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
@@ -403,8 +435,8 @@ export default function search() {
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id}/>
<ItemCardText item={item}/>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
@@ -417,8 +449,8 @@ export default function search() {
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id}/>
<ItemCardText item={item}/>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
@@ -431,8 +463,8 @@ export default function search() {
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId}/>
<ItemCardText item={item}/>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
@@ -444,14 +476,14 @@ export default function search() {
header="Request Movies"
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id}/>
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header="Request Series"
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id}/>
<JellyseerrPoster item={item} key={item.id} />
)}
/>
</>
@@ -470,7 +502,7 @@ export default function search() {
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 && searchType === 'Library' ? (
) : debouncedSearch.length === 0 && searchType === "Library" ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
@@ -482,11 +514,14 @@ export default function search() {
</TouchableOpacity>
))}
</View>
) : debouncedSearch.length === 0 && searchType === 'Discover' ? (
<View className="mt-4 flex flex-col space-y-2 px-2">
{sortBy?.(jellyseerrDiscoverSettings?.filter(s => s.enabled), 'order')
.map((slide) => <DiscoverSlide key={slide.id} slide={slide}/>)
}
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
<View className="flex flex-col px-4">
{sortBy?.(
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
"order"
).map((slide) => (
<DiscoverSlide key={slide.id} slide={slide} />
))}
</View>
) : null}
</View>
@@ -502,7 +537,12 @@ type Props<T> = {
header?: string;
};
const SearchItemWrapper = <T extends unknown> ({ ids, items, renderItem, header }: PropsWithChildren<Props<T>>) => {
const SearchItemWrapper = <T extends unknown>({
ids,
items,
renderItem,
header,
}: PropsWithChildren<Props<T>>) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -542,14 +582,11 @@ const SearchItemWrapper = <T extends unknown> ({ ids, items, renderItem, header
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{
data && data?.length > 0
? data.map((item) => renderItem(item))
:
items && items?.length > 0
? items.map(i => renderItem(i))
: undefined
}
{data && data?.length > 0
? data.map((item) => renderItem(item))
: items && items?.length > 0
? items.map((i) => renderItem(i))
: undefined}
</ScrollView>
</>
);

View File

@@ -282,13 +282,6 @@ export default function page() {
if (!item?.Id || !stream) return;
console.log(
"onProgress ~",
currentTimeInTicks,
isPlaying,
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
);
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,

View File

@@ -0,0 +1,53 @@
import { useGlobalSearchParams, useNavigation } from "expo-router";
import { useState, useCallback, useEffect, useMemo } from "react";
import { Button, Dimensions } from "react-native";
import { Alert, View } from "react-native";
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
export default function page() {
const searchParams = useGlobalSearchParams();
const navigation = useNavigation();
console.log(searchParams);
const { url } = searchParams as { url: string };
const videoId = useMemo(() => {
return url.split("v=")[1];
}, [url]);
const [playing, setPlaying] = useState(false);
const onStateChange = useCallback((state: PLAYER_STATES) => {
if (state === "ended") {
setPlaying(false);
Alert.alert("video has finished playing!");
}
}, []);
const togglePlaying = useCallback(() => {
setPlaying((prev) => !prev);
}, []);
useEffect(() => {
navigation.setOptions({
headerShown: false,
});
togglePlaying();
}, []);
const screenWidth = Dimensions.get("screen").width;
const screenHeight = Dimensions.get("screen").height;
return (
<View className="flex flex-col bg-black items-center justify-center h-full">
<YoutubePlayer
height={300}
play={playing}
videoId={videoId}
onChangeState={onStateChange}
width={screenWidth}
/>
</View>
);
}

View File

@@ -348,6 +348,13 @@ function Layout() {
header: () => null,
}}
/>
<Stack.Screen
name="(auth)/trailer/page"
options={{
presentation: "modal",
title: "",
}}
/>
<Stack.Screen
name="login"
options={{