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

@@ -47,7 +47,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
<TouchableOpacity
className={`
p-3 rounded-xl items-center justify-center
${loading || (disabled && "opacity-50")}
${(loading || disabled) && "opacity-50"}
${colorClasses}
${className}
`}

View File

@@ -68,7 +68,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
// Needs to automatically change the selected to the default values for default indexes.
useEffect(() => {
console.log(defaultAudioIndex, defaultSubtitleIndex);
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
@@ -220,7 +219,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
className="mr-1"
source={selectedOptions.mediaSource}
onChange={(val) => {
console.log(val);
setSelectedOptions(
(prev) =>
prev && {

View File

@@ -24,7 +24,7 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
<View className="flex flex-col overflow-visible">
<Text className="font-bold ">{title}</Text>
{subTitle && (
<Text uiTextView selectable className="text-xs">
<Text uiTextView selectable className="text-xs text-neutral-400">
{subTitle}
</Text>
)}

View File

@@ -3,10 +3,10 @@ import { View, ViewProps } from "react-native";
import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
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 { 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";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -21,7 +21,7 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
)}
{item.CommunityRating && (
<Badge
text={item.CommunityRating}
text={item.CommunityRating.toFixed(1)}
variant="gray"
iconLeft={<Ionicons name="star" size={14} color="gold" />}
/>
@@ -32,7 +32,11 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
variant="gray"
iconLeft={
<Image
source={require("@/assets/images/rotten-tomatoes.png")}
source={
item.CriticRating < 60
? require("@/assets/images/rotten-tomatoes.png")
: require("@/assets/images/not-rotten-tomatoes.svg")
}
style={{
width: 14,
height: 14,
@@ -45,22 +49,25 @@ export const Ratings: React.FC<Props> = ({ item, ...props }) => {
);
};
export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ result }) => {
const {jellyseerrApi} = useJellyseerr();
export const JellyserrRatings: React.FC<{ result: MovieResult | TvResult }> = ({
result,
}) => {
const { jellyseerrApi } = useJellyseerr();
const { data, isLoading } = useQuery({
queryKey: ['jellyseerr', result.id, result.mediaType, 'ratings'],
queryKey: ["jellyseerr", result.id, result.mediaType, "ratings"],
queryFn: async () => {
return result.mediaType === MediaType.MOVIE
? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id)
: jellyseerrApi?.tvRatings(result.id);
},
staleTime: (5).minutesToMilliseconds(),
retry: false,
enabled: !!jellyseerrApi,
});
return (isLoading || !!result.voteCount ||
return (
(isLoading ||
!!result.voteCount ||
(data?.criticsRating && !!data?.criticsScore) ||
(data?.audienceRating && !!data?.audienceScore)) && (
<View className="flex flex-row flex-wrap space-x-1">
@@ -72,7 +79,7 @@ export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ r
<Image
className="mr-1"
source={
data?.criticsRating === 'Rotten'
data?.criticsRating === "Rotten"
? require("@/utils/jellyseerr/src/assets/rt_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_fresh.svg")
}
@@ -92,7 +99,7 @@ export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ r
<Image
className="mr-1"
source={
data?.audienceRating === 'Spilled'
data?.audienceRating === "Spilled"
? require("@/utils/jellyseerr/src/assets/rt_aud_rotten.svg")
: require("@/utils/jellyseerr/src/assets/rt_aud_fresh.svg")
}
@@ -122,4 +129,5 @@ export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ r
)}
</View>
)
}
);
};

View File

@@ -1,26 +1,31 @@
import React, {useMemo} from "react";
import React, { useMemo } from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import {DiscoverEndpoint, Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {useInfiniteQuery} from "@tanstack/react-query";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import {
DiscoverEndpoint,
Endpoints,
useJellyseerr,
} from "@/hooks/useJellyseerr";
import { useInfiniteQuery } from "@tanstack/react-query";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {Text} from "@/components/common/Text";
import {FlashList} from "@shopify/flash-list";
import { Text } from "@/components/common/Text";
import { FlashList } from "@shopify/flash-list";
import { View } from "react-native";
interface Props {
slide: DiscoverSlider
slide: DiscoverSlider;
}
const DiscoverSlide: React.FC<Props> = ({slide}) => {
const {jellyseerrApi} = useJellyseerr();
const DiscoverSlide: React.FC<Props> = ({ slide }) => {
const { jellyseerrApi } = useJellyseerr();
const {data, isFetching, fetchNextPage, hasNextPage} = useInfiniteQuery({
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined = undefined;
let params: any = {
page: Number(pageParam)
}
page: Number(pageParam),
};
switch (slide.type) {
case DiscoverSliderType.TRENDING:
@@ -28,48 +33,68 @@ const DiscoverSlide: React.FC<Props> = ({slide}) => {
break;
case DiscoverSliderType.POPULAR_MOVIES:
case DiscoverSliderType.UPCOMING_MOVIES:
endpoint = Endpoints.DISCOVER_MOVIES
endpoint = Endpoints.DISCOVER_MOVIES;
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
params = { ...params, primaryReleaseDateGte: new Date().toISOString().split('T')[0]}
params = {
...params,
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
};
break;
case DiscoverSliderType.POPULAR_TV:
case DiscoverSliderType.UPCOMING_TV:
endpoint = Endpoints.DISCOVER_TV
endpoint = Endpoints.DISCOVER_TV;
if (slide.type === DiscoverSliderType.UPCOMING_TV)
params = {...params, firstAirDateGte: new Date().toISOString().split('T')[0]}
params = {
...params,
firstAirDateGte: new Date().toISOString().split("T")[0],
};
break;
}
return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) => ((lastPage?.page || pages?.findLast(p => p?.results.length)?.page) || 1) + 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
enabled: !!jellyseerrApi,
staleTime: 0
staleTime: 0,
});
const flatData = useMemo(() => data?.pages?.filter(p => p?.results.length).flatMap(p => p?.results), [data])
const flatData = useMemo(
() =>
data?.pages?.filter((p) => p?.results.length).flatMap((p) => p?.results),
[data]
);
return (
(flatData && flatData?.length > 0) && <>
<Text className="font-bold text-lg mb-2">{DiscoverSliderType[slide.type].toString().toTitle()}</Text>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={item => item!!.id.toString()}
estimatedItemSize={250}
data={flatData}
onEndReachedThreshold={1}
onEndReached={() => {
if (hasNextPage)
fetchNextPage()
}}
renderItem={({item}) =>
(item ? <JellyseerrPoster item={item as MovieResult | TvResult} /> : <></>)
}
/>
</>
)
}
flatData &&
flatData?.length > 0 && (
<View className="mb-4">
<Text className="font-bold text-lg mb-2">
{DiscoverSliderType[slide.type].toString().toTitle()}
</Text>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={(item) => item!!.id.toString()}
estimatedItemSize={250}
data={flatData}
onEndReachedThreshold={1}
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
renderItem={({ item }) =>
item ? (
<JellyseerrPoster item={item as MovieResult | TvResult} />
) : (
<></>
)
}
/>
</View>
)
);
};
export default DiscoverSlide;
export default DiscoverSlide;

View File

@@ -1,33 +1,37 @@
import {Text} from "@/components/common/Text";
import React, {useCallback, useMemo, useState} from "react";
import {Alert, TouchableOpacity, View} from "react-native";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
import {FlashList} from "@shopify/flash-list";
import {orderBy} from "lodash";
import {Tags} from "@/components/GenreTags";
import { Text } from "@/components/common/Text";
import React, { useCallback, useMemo, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { FlashList } from "@shopify/flash-list";
import { orderBy } from "lodash";
import { Tags } from "@/components/GenreTags";
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
import Season from "@/utils/jellyseerr/server/entity/Season";
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
import {Ionicons} from "@expo/vector-icons";
import {RoundButton} from "@/components/RoundButton";
import {useJellyseerr} from "@/hooks/useJellyseerr";
import {TvResult} from "@/utils/jellyseerr/server/models/Search";
import {useQuery} from "@tanstack/react-query";
import {HorizontalScroll} from "@/components/common/HorrizontalScroll";
import {Image} from "expo-image";
import {
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import { Ionicons } from "@expo/vector-icons";
import { RoundButton } from "@/components/RoundButton";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useQuery } from "@tanstack/react-query";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Image } from "expo-image";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { Loader } from "../Loader";
const JellyseerrSeasonEpisodes: React.FC<{details: TvDetails, seasonNumber: number}> = ({
details,
seasonNumber
}) => {
const {jellyseerrApi} = useJellyseerr();
const JellyseerrSeasonEpisodes: React.FC<{
details: TvDetails;
seasonNumber: number;
}> = ({ details, seasonNumber }) => {
const { jellyseerrApi } = useJellyseerr();
const {data: seasonWithEpisodes, isLoading} = useQuery({
const { data: seasonWithEpisodes, isLoading } = useQuery({
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
enabled: details.seasons.filter(s => s.seasonNumber !== 0).length > 0
})
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
});
return (
<HorizontalScroll
@@ -37,86 +41,102 @@ const JellyseerrSeasonEpisodes: React.FC<{details: TvDetails, seasonNumber: numb
estimatedItemSize={50}
data={seasonWithEpisodes?.episodes}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View className="w-2"/>}
renderItem={(item, index) => (
<View className="flex flex-col mt-2 w-44">
{item.stillPath && (
<View
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
>
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
/>
</View>
)}
<View className="shrink">
<Text numberOfLines={2} className="">
{item?.name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
{`S${item?.seasonNumber}:E${item?.episodeNumber}`}
</Text>
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
{item?.overview}
</Text>
</View>
<RenderItem key={index} item={item} index={index} />
)}
/>
)
}
);
};
const RenderItem = ({ item, index }: any) => {
const { jellyseerrApi } = useJellyseerr();
const [imageError, setImageError] = useState(false);
return (
<View className="flex flex-col w-44 mt-2">
<View className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
{!imageError ? (
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
onError={(e) => {
setImageError(true);
}}
/>
) : (
<View className="flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900">
<Ionicons
name="image-outline"
size={24}
color="white"
style={{ opacity: 0.4 }}
/>
</View>
)}
</View>
<View className="shrink mt-1">
<Text numberOfLines={2} className="">
{item.name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
{`S${item.seasonNumber}:E${item.episodeNumber}`}
</Text>
</View>
<Text numberOfLines={3} className="text-xs text-neutral-500 shrink">
{item.overview}
</Text>
</View>
);
};
const JellyseerrSeasons: React.FC<{
isLoading: boolean,
result?: TvResult,
details?: TvDetails
}> = ({
isLoading,
result,
details,
}) => {
if (!details)
return null;
isLoading: boolean;
result?: TvResult;
details?: TvDetails;
}> = ({ isLoading, result, details }) => {
if (!details) return null;
const {jellyseerrApi, requestMedia} = useJellyseerr();
const [seasonStates, setSeasonStates] = useState<{[key: number]: boolean}>();
const { jellyseerrApi, requestMedia } = useJellyseerr();
const [seasonStates, setSeasonStates] = useState<{
[key: number]: boolean;
}>();
const seasons = useMemo(() => {
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter((s: Season) => s.seasonNumber !== 0)
const requestedSeasons = details?.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons)
return details.seasons?.map((season) => {
return {
...season,
status:
// What our library status is
mediaInfoSeasons
?.find((mediaSeason: Season) => mediaSeason.seasonNumber === season.seasonNumber)
?.status
??
// What our request status is
requestedSeasons
?.find((s: Season) => s.seasonNumber === season.seasonNumber)
?.status
??
// Otherwise set it as unknown
MediaStatus.UNKNOWN
}
})
},
[details]
);
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter(
(s: Season) => s.seasonNumber !== 0
);
const requestedSeasons = details?.mediaInfo?.requests?.flatMap(
(r: MediaRequest) => r.seasons
);
return details.seasons?.map((season) => {
return {
...season,
status:
// What our library status is
mediaInfoSeasons?.find(
(mediaSeason: Season) =>
mediaSeason.seasonNumber === season.seasonNumber
)?.status ??
// What our request status is
requestedSeasons?.find(
(s: Season) => s.seasonNumber === season.seasonNumber
)?.status ??
// Otherwise set it as unknown
MediaStatus.UNKNOWN,
};
});
}, [details]);
const allSeasonsAvailable = useMemo(() =>
seasons?.every(season => season.status === MediaStatus.AVAILABLE),
const allSeasonsAvailable = useMemo(
() => seasons?.every((season) => season.status === MediaStatus.AVAILABLE),
[seasons]
)
);
const requestAll = useCallback(() => {
if (details && jellyseerrApi) {
@@ -125,48 +145,73 @@ const JellyseerrSeasons: React.FC<{
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: seasons
.filter(s => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
.map(s => s.seasonNumber)
})
.filter(
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
)
.map((s) => s.seasonNumber),
});
}
}, [jellyseerrApi, seasons, details])
}, [jellyseerrApi, seasons, details]);
const promptRequestAll = useCallback(() => (
Alert.alert('Request all?', 'Are you sure you want to request all seasons?', [
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'YES',
onPress: requestAll
},
])), [requestAll]);
const promptRequestAll = useCallback(
() =>
Alert.alert("Confirm", "Are you sure you want to request all seasons?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Yes",
onPress: requestAll,
},
]),
[requestAll]
);
if (isLoading)
return (
<View>
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text>
{!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} />
</RoundButton>
)}
</View>
<Loader />
</View>
);
return (
<FlashList
data={orderBy(details.seasons.filter(s => s.seasonNumber !== 0), 'seasonNumber', 'desc')}
data={orderBy(
details.seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"desc"
)}
ListHeaderComponent={() => (
<View className="flex flex-row justify-between items-end">
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text>
{!allSeasonsAvailable && (
<RoundButton
className="mb-2 pa-2"
onPress={promptRequestAll}
>
<Ionicons name="bag-add" color="white" size={26}/>
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} />
</RoundButton>
)}
</View>
)}
ItemSeparatorComponent={() => <View className="h-2" />}
estimatedItemSize={250}
renderItem={({item: season}) => (
renderItem={({ item: season }) => (
<>
<TouchableOpacity
onPress={() => setSeasonStates((prevState) => (
{...prevState, [season.seasonNumber]: !prevState?.[season.seasonNumber]}
))}
onPress={() =>
setSeasonStates((prevState) => ({
...prevState,
[season.seasonNumber]: !prevState?.[season.seasonNumber],
}))
}
className="px-4"
>
<View
className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4"
@@ -174,27 +219,43 @@ const JellyseerrSeasons: React.FC<{
>
<Tags
textClass=""
tags={[`Season ${season.seasonNumber}`, `${season.episodeCount} Episodes`]}
tags={[
`Season ${season.seasonNumber}`,
`${season.episodeCount} Episodes`,
]}
/>
{[0].map(() => {
const canRequest = seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status === MediaStatus.UNKNOWN
return <JellyseerrIconStatus
key={0}
onPress={canRequest ? () =>
requestMedia(
`${result?.name!!}, Season ${season.seasonNumber}`,
{
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: [season.seasonNumber]
}
) : undefined
}
className={canRequest ? 'bg-gray-700/40' : undefined}
mediaStatus={seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status}
showRequestIcon={canRequest}
/>
const canRequest =
seasons?.find((s) => s.seasonNumber === season.seasonNumber)
?.status === MediaStatus.UNKNOWN;
return (
<JellyseerrIconStatus
key={0}
onPress={
canRequest
? () =>
requestMedia(
`${result?.name!!}, Season ${
season.seasonNumber
}`,
{
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: [season.seasonNumber],
}
)
: undefined
}
className={canRequest ? "bg-gray-700/40" : undefined}
mediaStatus={
seasons?.find(
(s) => s.seasonNumber === season.seasonNumber
)?.status
}
showRequestIcon={canRequest}
/>
);
})}
</View>
</TouchableOpacity>
@@ -206,10 +267,9 @@ const JellyseerrSeasons: React.FC<{
/>
)}
</>
)
}
)}
/>
)
}
);
};
export default JellyseerrSeasons;
export default JellyseerrSeasons;

View File

@@ -11,6 +11,7 @@ import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { FlashList } from "@shopify/flash-list";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
@@ -43,10 +44,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
return (
<View>
<Text className="text-lg font-bold mb-2 px-4">Next up</Text>
<HorizontalScroll
<Text className="text-lg font-bold px-4 mb-2">Next up</Text>
<FlashList
contentContainerStyle={{ paddingLeft: 16 }}
horizontal
estimatedItemSize={172}
showsHorizontalScrollIndicator={false}
data={items}
renderItem={(item, index) => (
renderItem={({ item, index }) => (
<TouchableItemRouter
item={item}
key={index}

View File

@@ -19,7 +19,7 @@ type SeasonKeys = {
};
export type SeasonIndexState = {
[seriesId: string]: number | null | undefined;
[seriesId: string]: number | string | null | undefined;
};
export const SeasonDropdown: React.FC<Props> = ({

View File

@@ -30,7 +30,10 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const seasonIndex = seasonIndexState[item.Id ?? ""];
const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""],
[item, seasonIndexState]
);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
@@ -53,19 +56,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
return response.data.Items;
},
staleTime: 60,
enabled: !!api && !!user?.Id && !!item.Id,
});
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex]
);
const selectedSeasonId: string | null = useMemo(() => {
const season: BaseItemDto = seasons?.find(
(s: BaseItemDto) =>
s.IndexNumber === seasonIndex || s.Name === seasonIndex
);
if (!season?.Id) return null;
return season.Id!;
}, [seasons, seasonIndex]);
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
return [];
}
const res = await getTvShowsApi(api).getEpisodes({
seriesId: item.Id,
userId: user.Id,
@@ -74,6 +86,12 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
fields: ["MediaSources", "MediaStreams", "Overview"],
});
if (res.data.TotalRecordCount === 0)
console.warn(
"No episodes found for season with ID ~",
selectedSeasonId
);
return res.data.Items;
},
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
@@ -118,25 +136,28 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
seasons={seasons}
state={seasonIndexState}
onSelect={(season) => {
if (!item.Id) return;
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
[item.Id!]: season.IndexNumber ?? season.Name,
}));
}}
/>
<DownloadItems
title="Download Season"
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={20} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="download" size={20} color="#9333ea" />
)}
/>
{episodes?.length || 0 > 0 ? (
<DownloadItems
title="Download Season"
className="ml-2"
items={episodes || []}
MissingDownloadIconComponent={() => (
<Ionicons name="download" size={20} color="white" />
)}
DownloadedIconComponent={() => (
<Ionicons name="download" size={20} color="#9333ea" />
)}
/>
) : null}
</View>
<View className="px-4 flex flex-col my-4">
<View className="px-4 flex flex-col mt-4">
{isFetching ? (
<View
style={{
@@ -186,6 +207,13 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
</TouchableItemRouter>
))
)}
{(episodes?.length || 0) === 0 ? (
<View className="flex flex-col">
<Text className="text-neutral-500">
No episodes for this season
</Text>
</View>
) : null}
</View>
</View>
);

View File

@@ -0,0 +1,32 @@
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { View, TouchableOpacity, ViewProps } from "react-native";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const SeriesActions = ({ item, ...props }: Props) => {
const router = useRouter();
const trailerLink = useMemo(() => item.RemoteTrailers?.[0]?.Url, [item]);
const openTrailer = useCallback(async () => {
if (!trailerLink) return;
const encodedTrailerLink = encodeURIComponent(trailerLink);
router.push(`/trailer/page?url=${encodedTrailerLink}`);
}, [router, trailerLink]);
return (
<View className="" {...props}>
{trailerLink && (
<TouchableOpacity onPress={openTrailer}>
<Ionicons name="film-outline" size={24} color="white" />
</TouchableOpacity>
)}
</View>
);
};

View File

@@ -0,0 +1,64 @@
import { View } from "react-native";
import { Text } from "../common/Text";
import { Ratings } from "../Ratings";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo } from "react";
import { SeriesActions } from "./SeriesActions";
interface Props {
item: BaseItemDto;
}
export const SeriesHeader = ({ item }: Props) => {
const startYear = useMemo(() => {
if (item?.StartDate) {
return new Date(item.StartDate)
.toLocaleDateString("sv-SE", {
calendar: "gregory",
year: "numeric",
})
.toString()
.trim();
}
return item.ProductionYear?.toString().trim();
}, [item]);
const endYear = useMemo(() => {
if (item.EndDate) {
return new Date(item.EndDate)
.toLocaleDateString("sv-SE", {
calendar: "gregory",
year: "numeric",
})
.toString()
.trim();
}
return "";
}, [item]);
const yearString = useMemo(() => {
if (startYear && endYear) {
if (startYear === endYear) return startYear;
return `${startYear} - ${endYear}`;
}
if (startYear) {
return startYear;
}
if (endYear) {
return endYear;
}
return "";
}, [startYear, endYear]);
return (
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{yearString}</Text>
<View className="flex flex-row items-center justify-between">
<Ratings item={item} className="mb-2" />
<SeriesActions item={item} />
</View>
<Text className="">{item?.Overview}</Text>
</View>
);
};

View File

@@ -0,0 +1,207 @@
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { View } from "react-native";
import { Text } from "../common/Text";
import { useCallback, useRef, useState } from "react";
import { Input } from "../common/Input";
import { ListItem } from "../ListItem";
import { Loader } from "../Loader";
import { useSettings } from "@/utils/atoms/settings";
import { Button } from "../Button";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
import { toast } from "sonner-native";
import { useMutation } from "@tanstack/react-query";
export const JellyseerrSettings = () => {
const {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
} = useJellyseerr();
const [user] = useAtom(userAtom);
const [settings, updateSettings] = useSettings();
const [promptForJellyseerrPass, setPromptForJellyseerrPass] =
useState<boolean>(false);
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
>(undefined);
const [jellyseerrServerUrl, setjellyseerrServerUrl] = useState<
string | undefined
>(settings?.jellyseerrServerUrl || undefined);
const loginToJellyseerrMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl || !user?.Name || !jellyseerrPassword) {
throw new Error("Missing required information for login");
}
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.login(user.Name, jellyseerrPassword);
},
onSuccess: (user) => {
setJellyseerrUser(user);
updateSettings({ jellyseerrServerUrl });
},
onError: () => {
toast.error("Failed to login");
},
onSettled: () => {
setJellyseerrPassword(undefined);
},
});
const testJellyseerrServerUrlMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl || jellyseerrApi) return null;
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
return jellyseerrTempApi.test();
},
onSuccess: (result) => {
if (result && result.isValid) {
if (result.requiresPass) {
setPromptForJellyseerrPass(true);
} else {
updateSettings({ jellyseerrServerUrl });
}
} else {
setPromptForJellyseerrPass(false);
setjellyseerrServerUrl(undefined);
clearAllJellyseerData();
}
},
});
const clearData = () => {
clearAllJellyseerData().finally(() => {
setjellyseerrServerUrl(undefined);
setPromptForJellyseerrPass(false);
});
};
return (
<View className="mt-4">
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
<View>
{jellyseerrUser ? (
<View className="flex flex-col rounded-xl overflow-hidden bg-neutral-900 pt-0 divide-y divide-neutral-800">
<ListItem
title="Total media requests"
subTitle={jellyseerrUser?.requestCount?.toString()}
/>
<ListItem
title="Movie quota limit"
subTitle={
jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"
}
/>
<ListItem
title="Movie quota days"
subTitle={
jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"
}
/>
<ListItem
title="TV quota limit"
subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}
/>
<ListItem
title="TV quota days"
subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}
/>
<View className="p-4">
<Button color="red" onPress={clearData}>
Reset Jellyseerr config
</Button>
</View>
</View>
) : (
<View className="flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900">
<Text className="text-xs text-red-600 mb-2">
This integration is in its early stages. Expect things to change.
</Text>
<Text className="font-bold mb-1">Server URL</Text>
<View className="flex flex-col shrink mb-2">
<Text className="text-xs text-gray-600">
Example: http(s)://your-host.url
</Text>
<Text className="text-xs text-gray-600">
(add port if required)
</Text>
</View>
<Input
placeholder="Jellyseerr URL..."
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={setjellyseerrServerUrl}
editable={!testJellyseerrServerUrlMutation.isPending}
/>
<Button
loading={testJellyseerrServerUrlMutation.isPending}
disabled={testJellyseerrServerUrlMutation.isPending}
color={promptForJellyseerrPass ? "red" : "purple"}
className="h-12 mt-2"
onPress={() => {
if (promptForJellyseerrPass) {
clearData();
return;
}
testJellyseerrServerUrlMutation.mutate();
}}
style={{
marginBottom: 8,
}}
>
{promptForJellyseerrPass ? "Clear" : "Save"}
</Button>
<View
pointerEvents={promptForJellyseerrPass ? "auto" : "none"}
style={{
opacity: promptForJellyseerrPass ? 1 : 0.5,
}}
>
<Text className="font-bold mb-2">Password</Text>
<Input
autoFocus={true}
focusable={true}
placeholder={`Enter password for Jellyfin user ${user?.Name}`}
value={jellyseerrPassword}
keyboardType="default"
secureTextEntry={true}
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
onChangeText={setJellyseerrPassword}
editable={
!loginToJellyseerrMutation.isPending &&
promptForJellyseerrPass
}
/>
<Button
loading={loginToJellyseerrMutation.isPending}
disabled={loginToJellyseerrMutation.isPending}
color="purple"
className="h-12 mt-2"
onPress={() => loginToJellyseerrMutation.mutate()}
>
Login
</Button>
</View>
</View>
)}
</View>
</View>
);
};

View File

@@ -57,8 +57,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
updateSettings(update);
console.log("update", update);
let updatePayload = {
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
PlayDefaultAudioTrack:
@@ -84,8 +82,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
"";
console.log("updatePayload", updatePayload);
updateUserConfiguration(updatePayload);
};

View File

@@ -21,9 +21,8 @@ import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import React, {useCallback, useEffect, useState} from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
Alert,
Linking,
Switch,
TouchableOpacity,
@@ -41,32 +40,23 @@ import { Stepper } from "@/components/inputs/Stepper";
import { MediaProvider } from "./MediaContext";
import { SubtitleToggles } from "./SubtitleToggles";
import { AudioToggles } from "./AudioToggles";
import {JellyseerrApi, useJellyseerr} from "@/hooks/useJellyseerr";
import {ListItem} from "@/components/ListItem";
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
import { ListItem } from "@/components/ListItem";
import { JellyseerrSettings } from "./Jellyseerr";
interface Props extends ViewProps {}
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { setProcesses } = useDownload();
const {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser ,
clearAllJellyseerData
} = useJellyseerr();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState<string>("");
const [jellyseerrPassword, setJellyseerrPassword] = useState<string | undefined>(undefined);
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const [jellyseerrServerUrl, setjellyseerrServerUrl] =
useState<string | undefined>(settings?.jellyseerrServerUrl || undefined);
const queryClient = useQueryClient();
/********************
@@ -121,54 +111,6 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
staleTime: 0,
});
const promptForJellyseerrLogin = useCallback(() =>
Alert.prompt(
'Enter jellyfin password',
`Enter password for jellyfin user ${user?.Name}`,
(input) => setJellyseerrPassword(input),
'secure-text'
),
[user, setJellyseerrPassword]
);
const testJellyseerrServerUrl = useCallback(async () => {
if (!jellyseerrServerUrl || jellyseerrApi)
return;
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
jellyseerrTempApi.test().then(result => {
if (result.isValid) {
if (result.requiresPass)
promptForJellyseerrLogin()
else
updateSettings({jellyseerrServerUrl})
}
else {
setjellyseerrServerUrl(undefined);
clearAllJellyseerData();
}
})
}, [jellyseerrServerUrl])
useEffect(() => {
if (jellyseerrServerUrl && user?.Name && jellyseerrPassword) {
const jellyseerrTempApi = new JellyseerrApi(jellyseerrServerUrl);
jellyseerrTempApi.login(user?.Name, jellyseerrPassword)
.then(user => {
setJellyseerrUser(user);
updateSettings({jellyseerrServerUrl})
})
.catch(() => {
toast.error("Failed to login to jellyseerr!")
})
.finally(() => {
setJellyseerrPassword(undefined);
})
}
}, [user, jellyseerrServerUrl, jellyseerrPassword]);
if (!settings) return null;
return (
@@ -695,66 +637,7 @@ export const SettingToggles: React.FC<Props> = ({ ...props }) => {
</View>
</View>
<View className="mt-4">
<Text className="text-lg font-bold mb-2">Jellyseerr</Text>
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
{jellyseerrUser && <>
<View className="flex flex-col overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<ListItem title="Total media requests" subTitle={jellyseerrUser?.requestCount?.toString()}/>
<ListItem title="Movie quota limit" subTitle={jellyseerrUser?.movieQuotaLimit?.toString() ?? "Unlimited"}/>
<ListItem title="Movie quota days" subTitle={jellyseerrUser?.movieQuotaDays?.toString() ?? "Unlimited"}/>
<ListItem title="TV quota limit" subTitle={jellyseerrUser?.tvQuotaLimit?.toString() ?? "Unlimited"}/>
<ListItem title="TV quota days" subTitle={jellyseerrUser?.tvQuotaDays?.toString() ?? "Unlimited"}/>
</View>
</>}
<View
pointerEvents={
!jellyseerrUser || jellyseerrPassword ? "auto" : "none"
}
className={`
${
!jellyseerrUser || jellyseerrPassword
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col bg-neutral-900 px-4 py-4">
<View className="flex flex-col shrink mb-2">
<View className="flex flex-row justify-between items-center">
<Text className="font-semibold">
Server URL
</Text>
</View>
<Text className="text-xs opacity-50">
Set the URL for your jellyseerr instance.
</Text>
<Text className="text-xs text-gray-600">Example: http(s)://your-host.url</Text>
<Text className="text-xs text-gray-600 mb-1">(add port if required)</Text>
<Text className="text-xs text-red-600">This integration is in its early stages. Expect things to change.</Text>
</View>
<Input
placeholder="Jellyseerrs server URL..."
value={settings?.jellyseerrServerUrl ?? jellyseerrServerUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={setjellyseerrServerUrl}
/>
</View>
</View>
<Button
color="purple"
className="h-12 mt-2"
onPress={() => jellyseerrUser
? clearAllJellyseerData().finally(() => setjellyseerrServerUrl(undefined))
: testJellyseerrServerUrl()
}
>
{jellyseerrUser ? "Clear jellyseerr data" : "Save"}
</Button>
</View>
</View>
<JellyseerrSettings />
</View>
);
};

View File

@@ -20,7 +20,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const fetchInitialVolume = async () => {
try {
const { volume: initialVolume } = await VolumeManager.getVolume();
console.log("initialVolume", initialVolume);
volume.value = initialVolume * 100;
} catch (error) {
console.error("Error fetching initial volume:", error);
@@ -39,7 +38,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const handleValueChange = async (value: number) => {
volume.value = value;
console.log("volume through slider", value);
await VolumeManager.setVolume(value / 100);
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS
@@ -48,7 +46,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
useEffect(() => {
const volumeListener = VolumeManager.addVolumeListener((result) => {
console.log("Volume through device", result.volume);
volume.value = result.volume * 100;
setVisibility(true);

View File

@@ -14,7 +14,6 @@ const BrightnessSlider = () => {
useEffect(() => {
const fetchInitialBrightness = async () => {
const initialBrightness = await Brightness.getBrightnessAsync();
console.log("initialBrightness", initialBrightness);
brightness.value = initialBrightness * 100;
};
fetchInitialBrightness();

View File

@@ -240,8 +240,6 @@ export const Controls: React.FC<Props> = ({
? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress);
console.log("remaining: ", remaining);
setCurrentTime(current);
setRemainingTime(remaining);
},
@@ -349,7 +347,6 @@ export const Controls: React.FC<Props> = ({
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
try {
const curr = progress.value;
console.log(curr);
if (curr !== undefined) {
const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime)
@@ -375,8 +372,6 @@ export const Controls: React.FC<Props> = ({
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
console.log("time, ", time);
return (
<View
style={{

View File

@@ -35,7 +35,6 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
},
(finished) => {
if (finished && onFinish) {
console.log("finish");
runOnJS(onFinish)();
}
}

View File

@@ -106,19 +106,12 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL &&
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
console.log(
"Set external subtitle: ",
api?.basePath + sub.deliveryUrl
);
} else {
console.log("Set sub index: ", sub.index);
setSubtitleTrack && setSubtitleTrack(sub.index);
}
router.setParams({
subtitleIndex: sub.index.toString(),
});
console.log("Subtitle: ", sub);
}}
>
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>

View File

@@ -66,7 +66,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
console.log("sortedSubtitles", sortedSubtitles);
return [disableSubtitle, ...sortedSubtitles];
}
@@ -104,7 +103,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const ChangeTranscodingAudio = useCallback(
(audioIndex: number) => {
console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex);
const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "",
@@ -167,7 +165,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
}
key={`subtitle-item-${idx}`}
onValueChange={() => {
console.log("sub", sub);
if (
subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream
@@ -216,7 +213,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
value={audioIndex === track.index.toString()}
onValueChange={() => {
if (audioIndex === track.index.toString()) return;
console.log("Setting audio track to: ", track.index);
router.setParams({
audioIndex: track.index.toString(),
});