fix: resolve merge conflict in useSeerr.ts - keep improved BCP 47 locale logic

This commit is contained in:
Uruk
2026-01-12 11:40:13 +01:00
384 changed files with 114648 additions and 9463 deletions

View File

@@ -2,12 +2,17 @@ import type {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
import { router, useSegments } from "expo-router";
import { useSegments } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import useRouter from "@/hooks/useAppRouter";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -23,6 +28,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
const segments = useSegments();
const { t } = useTranslation();
const router = useRouter();
const from = (segments as string[])[2];
const destinctPeople = useMemo(() => {
@@ -50,7 +56,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
<HorizontalScroll
loading={loading}
keyExtractor={(i, _idx) => i.Id?.toString() || ""}
height={247}
height={POSTER_CAROUSEL_HEIGHT}
data={destinctPeople}
renderItem={(i) => (
<TouchableOpacity
@@ -65,8 +71,12 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
className='flex flex-col w-28'
>
<Poster id={i.Id} url={getPrimaryImageUrl({ api, item: i })} />
<Text className='mt-2'>{i.Name}</Text>
<Text className='text-xs opacity-50'>{i.Role}</Text>
<Text className='mt-2' numberOfLines={1}>
{i.Name}
</Text>
<Text className='text-xs opacity-50' numberOfLines={1}>
{i.Role}
</Text>
</TouchableOpacity>
)}
/>

View File

@@ -1,9 +1,13 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import type React from "react";
import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import useRouter from "@/hooks/useAppRouter";
// Matches `w-28` poster cards (approx 112px wide, 10/15 aspect ratio) + 2 lines of text.
const POSTER_CAROUSEL_HEIGHT = 220;
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { HorizontalScroll } from "../common/HorizontalScroll";
@@ -17,6 +21,7 @@ interface Props extends ViewProps {
export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const { t } = useTranslation();
const router = useRouter();
return (
<View {...props}>
@@ -25,7 +30,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
</Text>
<HorizontalScroll
data={[item]}
height={247}
height={POSTER_CAROUSEL_HEIGHT}
renderItem={(item, _index) => (
<TouchableOpacity
key={item?.Id}
@@ -38,7 +43,7 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
id={item?.Id}
url={getPrimaryImageUrlById({ api, id: item?.ParentId })}
/>
<Text>{item?.SeriesName}</Text>
<Text numberOfLines={1}>{item?.SeriesName}</Text>
</TouchableOpacity>
)}
/>

View File

@@ -1,7 +1,7 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import useRouter from "@/hooks/useAppRouter";
interface Props extends ViewProps {
item: BaseItemDto;

View File

@@ -2,9 +2,9 @@ import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
import useRouter from "@/hooks/useAppRouter";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Button } from "../Button";

View File

@@ -54,29 +54,28 @@ export const SeasonDropdown: React.FC<Props> = ({
[state, item, keys],
);
// Always use IndexNumber for Season objects (not keys.index which is for the item)
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
Number(a[keys.index]) - Number(b[keys.index]);
Number(a.IndexNumber) - Number(b.IndexNumber);
const optionGroups = useMemo(
() => [
{
options:
seasons?.sort(sortByIndex).map((season: any) => {
const title =
season[keys.title] ||
season.Name ||
`Season ${season.IndexNumber}`;
const title = season.Name || `Season ${season.IndexNumber}`;
return {
type: "radio" as const,
label: title,
value: season.Id || season.IndexNumber,
selected: Number(season[keys.index]) === Number(seasonIndex),
// Compare season's IndexNumber with the selected seasonIndex
selected: Number(season.IndexNumber) === Number(seasonIndex),
onPress: () => onSelect(season),
};
}) || [],
},
],
[seasons, keys, seasonIndex, onSelect],
[seasons, seasonIndex, onSelect],
);
useEffect(() => {

View File

@@ -1,12 +1,14 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";
import { TouchableOpacity, type ViewStyle } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import { getDownloadedEpisodesBySeasonId } from "@/utils/downloads/offline-series";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import {
HorizontalScroll,
@@ -17,7 +19,6 @@ import { ItemCardText } from "../ItemCardText";
interface Props {
item?: BaseItemDto | null;
loading?: boolean;
isOffline?: boolean;
style?: ViewStyle;
containerStyle?: ViewStyle;
}
@@ -25,17 +26,14 @@ interface Props {
export const SeasonEpisodesCarousel: React.FC<Props> = ({
item,
loading,
isOffline,
style,
containerStyle,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const isOffline = useOfflineMode();
const router = useRouter();
const { getDownloadedItems } = useDownload();
const downloadedFiles = useMemo(
() => getDownloadedItems(),
[getDownloadedItems],
);
const scrollRef = useRef<HorizontalScrollRef>(null);
@@ -51,11 +49,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
queryKey: ["episodes", seasonId, isOffline],
queryFn: async () => {
if (isOffline) {
return downloadedFiles
?.filter(
(f) => f.item.Type === "Episode" && f.item.SeasonId === seasonId,
)
.map((f) => f.item);
return getDownloadedEpisodesBySeasonId(getDownloadedItems(), seasonId!);
}
if (!api || !user?.Id || !item?.SeriesId) return [];
const response = await getTvShowsApi(api).getEpisodes({
@@ -73,7 +67,7 @@ export const SeasonEpisodesCarousel: React.FC<Props> = ({
});
return response.data.Items as BaseItemDto[];
},
enabled: !!api && !!user?.Id && !!seasonId,
enabled: !!seasonId && (isOffline || (!!api && !!user?.Id)),
});
useEffect(() => {

View File

@@ -10,7 +10,13 @@ import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useOfflineMode } from "@/providers/OfflineModeProvider";
import {
buildOfflineSeasons,
getDownloadedEpisodesForSeason,
} from "@/utils/downloads/offline-series";
import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { Text } from "../common/Text";
@@ -31,6 +37,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const { t } = useTranslation();
const isOffline = useOfflineMode();
const { getDownloadedItems, downloadedItems } = useDownload();
const seasonIndex = useMemo(
() => seasonIndexState[item.Id ?? ""],
@@ -38,8 +46,12 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
queryKey: ["seasons", item.Id, isOffline, downloadedItems.length],
queryFn: async () => {
if (isOffline) {
return buildOfflineSeasons(getDownloadedItems(), item.Id!);
}
if (!api || !user?.Id || !item.Id) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Shows/${item.Id}/Seasons`,
@@ -58,8 +70,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return response.data.Items;
},
staleTime: 60,
enabled: !!api && !!user?.Id && !!item.Id,
staleTime: isOffline ? Infinity : 60,
enabled: isOffline || (!!api && !!user?.Id && !!item.Id),
});
const selectedSeasonId: string | null = useMemo(() => {
@@ -73,9 +85,33 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return season.Id!;
}, [seasons, seasonIndex]);
// For offline mode, we use season index number instead of ID
const selectedSeasonNumber = useMemo(() => {
if (!isOffline) return null;
const season = seasons?.find(
(s: BaseItemDto) =>
s.IndexNumber === seasonIndex || s.Name === seasonIndex,
);
return season?.IndexNumber ?? null;
}, [isOffline, seasons, seasonIndex]);
const { data: episodes, isPending } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryKey: [
"episodes",
item.Id,
isOffline ? selectedSeasonNumber : selectedSeasonId,
isOffline,
downloadedItems.length,
],
queryFn: async () => {
if (isOffline) {
return getDownloadedEpisodesForSeason(
getDownloadedItems(),
item.Id!,
selectedSeasonNumber!,
);
}
if (!api || !user?.Id || !item.Id || !selectedSeasonId) {
return [];
}
@@ -85,7 +121,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
userId: user.Id,
seasonId: selectedSeasonId,
enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
});
@@ -97,7 +132,10 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
return res.data.Items;
},
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
staleTime: isOffline ? Infinity : 0,
enabled: isOffline
? !!item.Id && selectedSeasonNumber !== null
: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
// Used for height calculation
@@ -127,7 +165,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
}));
}}
/>
{episodes?.length ? (
{episodes?.length && !isOffline ? (
<View className='flex flex-row items-center space-x-2'>
<DownloadItems
title={t("item_card.download.download_season")}
@@ -180,9 +218,11 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
{runtimeTicksToSeconds(e.RunTimeTicks)}
</Text>
</View>
<View className='self-start ml-auto -mt-0.5'>
<DownloadSingleItem item={e} />
</View>
{!isOffline && (
<View className='self-start ml-auto -mt-0.5'>
<DownloadSingleItem item={e} />
</View>
)}
</View>
<Text

View File

@@ -14,11 +14,11 @@ import { Alert, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Tags } from "@/components/GenreTags";
import { dateOpts } from "@/components/jellyseerr/DetailFacts";
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { RoundButton } from "@/components/RoundButton";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { dateOpts } from "@/components/seerr/DetailFacts";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
import { useSeerr } from "@/hooks/useSeerr";
import {
MediaStatus,
MediaType,
@@ -30,15 +30,15 @@ import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Loader } from "../Loader";
const JellyseerrSeasonEpisodes: React.FC<{
const SeerrSeasonEpisodes: React.FC<{
details: TvDetails;
seasonNumber: number;
}> = ({ details, seasonNumber }) => {
const { jellyseerrApi } = useJellyseerr();
const { seerrApi } = useSeerr();
const { data: seasonWithEpisodes, isLoading } = useQuery({
queryKey: ["jellyseerr", details.id, "season", seasonNumber],
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
queryKey: ["seerr", details.id, "season", seasonNumber],
queryFn: async () => seerrApi?.tvSeason(details.id, seasonNumber),
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
});
@@ -57,11 +57,7 @@ const JellyseerrSeasonEpisodes: React.FC<{
};
const RenderItem = ({ item }: any) => {
const {
jellyseerrApi,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
@@ -83,7 +79,7 @@ const RenderItem = ({ item }: any) => {
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.imageProxy(item.stillPath),
uri: seerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit='cover'
@@ -131,7 +127,7 @@ const RenderItem = ({ item }: any) => {
);
};
const JellyseerrSeasons: React.FC<{
const SeerrSeasons: React.FC<{
isLoading: boolean;
details?: TvDetails;
hasAdvancedRequest?: boolean;
@@ -148,7 +144,7 @@ const JellyseerrSeasons: React.FC<{
hasAdvancedRequest,
onAdvancedRequest,
}) => {
const { jellyseerrApi, requestMedia } = useJellyseerr();
const { seerrApi, requestMedia } = useSeerr();
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
{},
);
@@ -181,7 +177,7 @@ const JellyseerrSeasons: React.FC<{
);
const requestAll = useCallback(() => {
if (details && jellyseerrApi) {
if (details && seerrApi) {
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
@@ -198,7 +194,7 @@ const JellyseerrSeasons: React.FC<{
requestMedia(details.name, body, refetch);
}
}, [
jellyseerrApi,
seerrApi,
seasons,
details,
hasAdvancedRequest,
@@ -210,15 +206,15 @@ const JellyseerrSeasons: React.FC<{
const promptRequestAll = useCallback(
() =>
Alert.alert(
t("jellyseerr.confirm"),
t("jellyseerr.are_you_sure_you_want_to_request_all_seasons"),
t("seerr.confirm"),
t("seerr.are_you_sure_you_want_to_request_all_seasons"),
[
{
text: t("jellyseerr.cancel"),
text: t("seerr.cancel"),
style: "cancel",
},
{
text: t("jellyseerr.yes"),
text: t("seerr.yes"),
onPress: requestAll,
},
],
@@ -301,10 +297,10 @@ const JellyseerrSeasons: React.FC<{
<Tags
textClass=''
tags={[
t("jellyseerr.season_number", {
t("seerr.season_number", {
season_number: season.seasonNumber,
}),
t("jellyseerr.number_episodes", {
t("seerr.number_episodes", {
episode_number: season.episodeCount,
}),
]}
@@ -312,7 +308,7 @@ const JellyseerrSeasons: React.FC<{
{[0].map(() => {
const canRequest = season.status === MediaStatus.UNKNOWN;
return (
<JellyseerrStatusIcon
<SeerrStatusIcon
key={0}
onPress={() =>
requestSeason(canRequest, season.seasonNumber)
@@ -326,7 +322,7 @@ const JellyseerrSeasons: React.FC<{
</View>
</TouchableOpacity>
{seasonStates?.[season.seasonNumber] && (
<JellyseerrSeasonEpisodes
<SeerrSeasonEpisodes
key={season.seasonNumber}
details={details}
seasonNumber={season.seasonNumber}
@@ -338,4 +334,4 @@ const JellyseerrSeasons: React.FC<{
);
};
export default JellyseerrSeasons;
export default SeerrSeasons;