mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-04-29 11:56:38 +01:00
refactor: downloads to minimize prop drilling and improve layout and design (#1337)
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local> Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com> Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
This commit is contained in:
@@ -9,13 +9,14 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { type Href, router } from "expo-router";
|
||||
import { type Href } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Alert, Platform, Switch, View, type ViewProps } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
@@ -62,6 +63,7 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
const [user] = useAtom(userAtom);
|
||||
const [queue, _setQueue] = useAtom(queueAtom);
|
||||
const { settings } = useSettings();
|
||||
const router = useRouter();
|
||||
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
|
||||
|
||||
const { processes, startBackgroundDownload, downloadedItems } = useDownload();
|
||||
@@ -170,9 +172,11 @@ export const DownloadItems: React.FC<DownloadProps> = ({
|
||||
firstItem.Type !== "Episode"
|
||||
? "/downloads"
|
||||
: ({
|
||||
pathname: `/downloads/${firstItem.SeriesId}`,
|
||||
pathname: "/series/[id]",
|
||||
params: {
|
||||
episodeSeasonIndex: firstItem.ParentIndexNumber,
|
||||
id: firstItem.SeriesId!,
|
||||
seasonIndex: firstItem.ParentIndexNumber?.toString(),
|
||||
offline: "true",
|
||||
},
|
||||
} as Href),
|
||||
);
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
BottomSheetScrollView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export interface IntroSheetRef {
|
||||
@@ -24,6 +24,7 @@ export const IntroSheet = forwardRef<IntroSheetRef>((_, ref) => {
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
present: () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { AddToFavorites } from "./AddToFavorites";
|
||||
@@ -45,13 +46,13 @@ export type SelectedOptions = {
|
||||
|
||||
interface ItemContentProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
itemWithSources?: BaseItemDto | null;
|
||||
}
|
||||
|
||||
export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
({ item, isOffline, itemWithSources }) => {
|
||||
({ item, itemWithSources }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const { orientation } = useOrientation();
|
||||
const navigation = useNavigation();
|
||||
@@ -228,7 +229,6 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
<PlayButton
|
||||
selectedOptions={selectedOptions}
|
||||
item={item}
|
||||
isOffline={isOffline}
|
||||
colors={itemColors}
|
||||
/>
|
||||
<View className='w-1' />
|
||||
@@ -243,11 +243,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
</View>
|
||||
</View>
|
||||
{item.Type === "Episode" && (
|
||||
<SeasonEpisodesCarousel
|
||||
item={item}
|
||||
loading={loading}
|
||||
isOffline={isOffline}
|
||||
/>
|
||||
<SeasonEpisodesCarousel item={item} loading={loading} />
|
||||
)}
|
||||
|
||||
{!isOffline &&
|
||||
@@ -264,7 +260,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
|
||||
<CurrentSeries item={item} className='mb-2' />
|
||||
)}
|
||||
|
||||
<ItemPeopleSections item={item} isOffline={isOffline} />
|
||||
<ItemPeopleSections item={item} />
|
||||
|
||||
{!isOffline && <SimilarItems itemId={item.Id} />}
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||
import { BottomSheetView } from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -24,11 +23,13 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { getDownloadedItemById } from "@/providers/Downloads/database";
|
||||
import { useGlobalModal } from "@/providers/GlobalModalProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
|
||||
@@ -44,7 +45,6 @@ import type { SelectedOptions } from "./ItemContent";
|
||||
interface Props extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
item: BaseItemDto;
|
||||
selectedOptions: SelectedOptions;
|
||||
isOffline?: boolean;
|
||||
colors?: ThemeColors;
|
||||
}
|
||||
|
||||
@@ -54,9 +54,9 @@ const MIN_PLAYBACK_WIDTH = 15;
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
selectedOptions,
|
||||
isOffline,
|
||||
colors,
|
||||
}: Props) => {
|
||||
const isOffline = useOfflineMode();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const client = useRemoteMediaClient();
|
||||
const mediaStatus = useMediaStatus();
|
||||
@@ -300,6 +300,19 @@ export const PlayButton: React.FC<Props> = ({
|
||||
// Check if item is downloaded
|
||||
const downloadedItem = item.Id ? getDownloadedItemById(item.Id) : undefined;
|
||||
|
||||
// If already in offline mode, play downloaded file directly
|
||||
if (isOffline && downloadedItem) {
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id!,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
goToPlayer(queryParams.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// If online but file is downloaded, ask user which version to play
|
||||
if (downloadedItem) {
|
||||
if (Platform.OS === "android") {
|
||||
// Show bottom sheet for Android
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
@@ -14,6 +13,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import type { ThemeColors } from "@/hooks/useImageColorsReturn";
|
||||
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
|
||||
|
||||
@@ -7,7 +7,6 @@ import { RoundButton } from "./RoundButton";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
items: BaseItemDto[];
|
||||
isOffline?: boolean;
|
||||
size?: "default" | "large";
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
@@ -26,6 +25,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
|
||||
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView, type BlurViewProps } from "expo-blur";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Platform } from "react-native";
|
||||
import { Pressable, type PressableProps } from "react-native-gesture-handler";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
|
||||
interface Props extends BlurViewProps {
|
||||
background?: "blur" | "transparent";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||
import { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { type PropsWithChildren, useCallback } from "react";
|
||||
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
isOffline?: boolean;
|
||||
}
|
||||
|
||||
export const itemRouter = (item: BaseItemDto, from: string) => {
|
||||
@@ -134,26 +136,20 @@ export const getItemNavigation = (item: BaseItemDto, _from: string) => {
|
||||
|
||||
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
item,
|
||||
isOffline = false,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const markAsPlayedStatus = useMarkAsPlayed([item]);
|
||||
const { isFavorite, toggleFavorite } = useFavorite(item);
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
const { deleteFile } = useDownload();
|
||||
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
// For offline mode, we still need to use query params
|
||||
if (isOffline) {
|
||||
const url = `${itemRouter(item, from)}&offline=true`;
|
||||
router.push(url as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Force music libraries to navigate via the explicit string route.
|
||||
// This avoids losing the dynamic [libraryId] param when going through a nested navigator.
|
||||
if ("CollectionType" in item && item.CollectionType === "music") {
|
||||
@@ -163,7 +159,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
|
||||
const navigation = getItemNavigation(item, from);
|
||||
router.push(navigation as any);
|
||||
}, [from, isOffline, item, router]);
|
||||
}, [from, item, router]);
|
||||
|
||||
const showActionSheet = useCallback(() => {
|
||||
if (
|
||||
@@ -179,14 +175,19 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
"Mark as Played",
|
||||
"Mark as Not Played",
|
||||
isFavorite ? "Unmark as Favorite" : "Mark as Favorite",
|
||||
...(isOffline ? ["Delete Download"] : []),
|
||||
"Cancel",
|
||||
];
|
||||
const cancelButtonIndex = options.length - 1;
|
||||
const destructiveButtonIndex = isOffline
|
||||
? cancelButtonIndex - 1
|
||||
: undefined;
|
||||
|
||||
showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
cancelButtonIndex,
|
||||
destructiveButtonIndex,
|
||||
},
|
||||
async (selectedIndex) => {
|
||||
if (selectedIndex === 0) {
|
||||
@@ -195,6 +196,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
await markAsPlayedStatus(false);
|
||||
} else if (selectedIndex === 2) {
|
||||
toggleFavorite();
|
||||
} else if (isOffline && selectedIndex === 3 && item.Id) {
|
||||
deleteFile(item.Id);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -203,6 +206,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
isFavorite,
|
||||
markAsPlayedStatus,
|
||||
toggleFavorite,
|
||||
isOffline,
|
||||
deleteFile,
|
||||
item.Id,
|
||||
]);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
} from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { calculateSmoothedETA } from "@/providers/Downloads/hooks/useDownloadSpeedCalculator";
|
||||
|
||||
@@ -61,7 +61,6 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
||||
return (
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
isOffline={true}
|
||||
onLongPress={showActionSheet}
|
||||
className='flex flex-col mb-4'
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
||||
}, [showActionSheetWithOptions, handleDeleteFile]);
|
||||
|
||||
return (
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline>
|
||||
<TouchableItemRouter onLongPress={showActionSheet} item={item}>
|
||||
{base64Image ? (
|
||||
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
|
||||
<Image
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { router } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Text } from "../common/Text";
|
||||
@@ -14,6 +14,7 @@ import { Text } from "../common/Text";
|
||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
const { deleteItems } = useDownload();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
const router = useRouter();
|
||||
|
||||
const base64Image = useMemo(() => {
|
||||
return storage.getString(items[0].SeriesId!);
|
||||
@@ -46,7 +47,12 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push(`/downloads/${items[0].SeriesId}`)}
|
||||
onPress={() =>
|
||||
router.push({
|
||||
pathname: "/series/[id]",
|
||||
params: { id: items[0].SeriesId!, offline: "true" },
|
||||
})
|
||||
}
|
||||
onLongPress={showActionSheet}
|
||||
>
|
||||
{base64Image ? (
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Api } from "@jellyfin/sdk";
|
||||
import type { BaseItemKind } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -10,6 +9,7 @@ import { Text, View } from "react-native";
|
||||
// PNG ASSET
|
||||
import heart from "@/assets/icons/heart.fill.png";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useNavigation, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -33,6 +33,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getUserViewsApi,
|
||||
} from "@jellyfin/sdk/lib/utils/api";
|
||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useRouter, useSegments } from "expo-router";
|
||||
import { useNavigation, useSegments } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -35,6 +35,7 @@ import { StreamystatsRecommendations } from "@/components/home/StreamystatsRecom
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { Image } from "expo-image";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Dimensions, View, type ViewProps } from "react-native";
|
||||
@@ -16,6 +16,7 @@ import Carousel, {
|
||||
type ICarouselInstance,
|
||||
Pagination,
|
||||
} from "react-native-reanimated-carousel";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
@@ -21,7 +21,6 @@ interface Props extends ViewProps {
|
||||
queryKey: QueryKey;
|
||||
queryFn: QueryFunction<BaseItemDto[]>;
|
||||
hideIfEmpty?: boolean;
|
||||
isOffline?: boolean;
|
||||
scrollY?: number; // For lazy loading
|
||||
enableLazyLoading?: boolean; // Enable/disable lazy loading
|
||||
}
|
||||
@@ -33,7 +32,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
queryFn,
|
||||
queryKey,
|
||||
hideIfEmpty = false,
|
||||
isOffline = false,
|
||||
scrollY = 0,
|
||||
enableLazyLoading = false,
|
||||
...props
|
||||
@@ -106,7 +104,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
|
||||
<TouchableItemRouter
|
||||
item={item}
|
||||
key={item.Id}
|
||||
isOffline={isOffline}
|
||||
className={`mr-2
|
||||
${orientation === "horizontal" ? "w-44" : "w-28"}
|
||||
`}
|
||||
|
||||
@@ -8,17 +8,14 @@ import { InteractionManager, View, type ViewProps } from "react-native";
|
||||
import { MoreMoviesWithActor } from "@/components/MoreMoviesWithActor";
|
||||
import { CastAndCrew } from "@/components/series/CastAndCrew";
|
||||
import { useItemPeopleQuery } from "@/hooks/useItemPeopleQuery";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
|
||||
interface Props extends ViewProps {
|
||||
item: BaseItemDto;
|
||||
isOffline: boolean;
|
||||
}
|
||||
|
||||
export const ItemPeopleSections: React.FC<Props> = ({
|
||||
item,
|
||||
isOffline,
|
||||
...props
|
||||
}) => {
|
||||
export const ItemPeopleSections: React.FC<Props> = ({ item, ...props }) => {
|
||||
const isOffline = useOfflineMode();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { TouchableOpacity, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import Poster from "@/components/posters/Poster";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
COMPANY_LOGO_IMAGE_FILTER,
|
||||
@@ -16,6 +17,7 @@ const CompanySlide: React.FC<
|
||||
> = ({ slide, data, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { useSegments } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TouchableOpacity, type ViewProps } from "react-native";
|
||||
import GenericSlideCard from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||
import Slide, { type SlideProps } from "@/components/jellyseerr/discover/Slide";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
|
||||
@@ -13,6 +14,7 @@ import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/consta
|
||||
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
const segments = useSegments();
|
||||
const { jellyseerrApi } = useJellyseerr();
|
||||
const router = useRouter();
|
||||
const from = (segments as string[])[2] || "(home)";
|
||||
|
||||
const navigate = useCallback(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import {
|
||||
@@ -23,6 +22,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ 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 { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { getLocalPath } from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useDeletePlaylist } from "@/hooks/usePlaylistMutations";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, {
|
||||
useCallback,
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import {
|
||||
audioStorageEvents,
|
||||
|
||||
@@ -2,13 +2,14 @@ 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 { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -24,6 +25,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(() => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { POSTER_CAROUSEL_HEIGHT } from "@/constants/Values";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
|
||||
import { HorizontalScroll } from "../common/HorizontalScroll";
|
||||
@@ -18,6 +18,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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Linking, Switch } from "react-native";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import { TFunction } from "i18next";
|
||||
import type React from "react";
|
||||
import { useMemo } from "react";
|
||||
@@ -8,6 +7,7 @@ import { Linking, Switch, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
import { PlatformDropdown } from "@/components/PlatformDropdown";
|
||||
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { t } from "i18next";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
export interface ContinueWatchingOverlayProps {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { StyleSheet, useWindowDimensions, View } from "react-native";
|
||||
import Animated, {
|
||||
@@ -15,12 +15,14 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import ContinueWatchingOverlay from "@/components/video-player/controls/ContinueWatchingOverlay";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useTrickplay } from "@/hooks/useTrickplay";
|
||||
import { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import { ticksToMs } from "@/utils/time";
|
||||
@@ -49,7 +51,6 @@ interface Props {
|
||||
enableTrickplay?: boolean;
|
||||
togglePlay: () => void;
|
||||
setShowControls: (shown: boolean) => void;
|
||||
offline?: boolean;
|
||||
mediaSource?: MediaSourceInfo | null;
|
||||
seek: (ticks: number) => void;
|
||||
startPictureInPicture?: () => Promise<void>;
|
||||
@@ -83,12 +84,12 @@ export const Controls: FC<Props> = ({
|
||||
aspectRatio = "default",
|
||||
isZoomedToFill = false,
|
||||
onZoomToggle,
|
||||
offline = false,
|
||||
api = null,
|
||||
downloadedFiles = undefined,
|
||||
playbackSpeed = 1.0,
|
||||
setPlaybackSpeed,
|
||||
}) => {
|
||||
const offline = useOfflineMode();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const lightHapticFeedback = useHaptic("light");
|
||||
@@ -110,7 +111,9 @@ export const Controls: FC<Props> = ({
|
||||
} = useTrickplay(item);
|
||||
|
||||
const min = useSharedValue(0);
|
||||
const max = useSharedValue(ticksToMs(item.RunTimeTicks || 0));
|
||||
// Regular value for use during render (avoids Reanimated warning)
|
||||
const maxMs = ticksToMs(item.RunTimeTicks || 0);
|
||||
const max = useSharedValue(maxMs);
|
||||
|
||||
// Animation values for controls
|
||||
const controlsOpacity = useSharedValue(showControls ? 1 : 0);
|
||||
@@ -303,7 +306,7 @@ export const Controls: FC<Props> = ({
|
||||
offline,
|
||||
api,
|
||||
downloadedFiles,
|
||||
max.value,
|
||||
maxMs,
|
||||
);
|
||||
|
||||
const goToItemCommon = useCallback(
|
||||
|
||||
@@ -2,11 +2,10 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useGlobalSearchParams } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||
import {
|
||||
HorizontalScroll,
|
||||
@@ -19,10 +18,16 @@ import {
|
||||
type SeasonIndexState,
|
||||
} from "@/components/series/SeasonDropdown";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import type { DownloadedItem } from "@/providers/Downloads/types";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
getDownloadedEpisodesForSeason,
|
||||
getDownloadedSeasonNumbers,
|
||||
} from "@/utils/downloads/offline-series";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { runtimeTicksToSeconds } from "@/utils/time";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
@@ -40,10 +45,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
const scrollToIndex = (index: number) => {
|
||||
scrollViewRef.current?.scrollToIndex(index, 100);
|
||||
};
|
||||
const { offline } = useGlobalSearchParams<{
|
||||
offline: string;
|
||||
}>();
|
||||
const isOffline = offline === "true";
|
||||
const isOffline = useOfflineMode();
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Set the initial season index
|
||||
useEffect(() => {
|
||||
@@ -56,10 +60,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}, []);
|
||||
|
||||
const { getDownloadedItems } = useDownload();
|
||||
const downloadedFiles = useMemo(
|
||||
() => getDownloadedItems(),
|
||||
[getDownloadedItems],
|
||||
);
|
||||
|
||||
const seasonIndex = seasonIndexState[item.ParentId ?? ""];
|
||||
|
||||
@@ -68,15 +68,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
const seriesEpisodes = downloadedFiles?.filter(
|
||||
(f: DownloadedItem) => f.item.SeriesId === item.SeriesId,
|
||||
);
|
||||
const seasonNumbers = Array.from(
|
||||
new Set(
|
||||
seriesEpisodes
|
||||
?.map((f: DownloadedItem) => f.item.ParentIndexNumber)
|
||||
.filter(Boolean),
|
||||
),
|
||||
const seasonNumbers = getDownloadedSeasonNumbers(
|
||||
getDownloadedItems(),
|
||||
item.SeriesId,
|
||||
);
|
||||
// Create fake season objects
|
||||
return seasonNumbers.map((seasonNumber) => ({
|
||||
@@ -117,14 +111,12 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
queryKey: ["episodes", item.SeriesId, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
if (isOffline) {
|
||||
if (!item.SeriesId) return [];
|
||||
return downloadedFiles
|
||||
?.filter(
|
||||
(f: DownloadedItem) =>
|
||||
f.item.SeriesId === item.SeriesId &&
|
||||
f.item.ParentIndexNumber === seasonIndex,
|
||||
)
|
||||
.map((f: DownloadedItem) => f.item);
|
||||
if (!item.SeriesId || typeof seasonIndex !== "number") return [];
|
||||
return getDownloadedEpisodesForSeason(
|
||||
getDownloadedItems(),
|
||||
item.SeriesId,
|
||||
seasonIndex,
|
||||
);
|
||||
}
|
||||
if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
|
||||
const res = await getTvShowsApi(api).getEpisodes({
|
||||
@@ -153,6 +145,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
// Don't prefetch when offline - data is already local
|
||||
if (isOffline) return;
|
||||
|
||||
for (const e of episodes || []) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["item", e.Id],
|
||||
@@ -168,7 +163,7 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
staleTime: 60 * 5 * 1000,
|
||||
});
|
||||
}
|
||||
}, [episodes]);
|
||||
}, [episodes, isOffline]);
|
||||
|
||||
// Scroll to the current item when episodes are fetched
|
||||
useEffect(() => {
|
||||
@@ -181,15 +176,24 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
}, [episodes, item.Id]);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
backgroundColor: "black",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
paddingTop:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.top : 0,
|
||||
paddingLeft:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
paddingRight:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
}}
|
||||
>
|
||||
<View className='flex-row items-center p-4 z-10'>
|
||||
<View
|
||||
style={{ padding: HEADER_LAYOUT.CONTAINER_PADDING }}
|
||||
className='flex-row items-center z-10'
|
||||
>
|
||||
{seasons && seasons.length > 0 && !episodesLoading && episodes && (
|
||||
<SeasonDropdown
|
||||
item={item}
|
||||
@@ -207,9 +211,9 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
onPress={async () => {
|
||||
close();
|
||||
}}
|
||||
className='aspect-square flex flex-col bg-neutral-800/90 rounded-xl items-center justify-center p-2 ml-auto'
|
||||
className='aspect-square flex flex-col rounded-xl items-center justify-center p-2 ml-auto'
|
||||
>
|
||||
<Ionicons name='close' size={24} color='white' />
|
||||
<Ionicons name='close' size={ICON_SIZES.HEADER} color='white' />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -274,6 +278,6 @@ export const EpisodeList: React.FC<Props> = ({ item, close, goToItem }) => {
|
||||
showsHorizontalScrollIndicator={false}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,15 +3,15 @@ import type {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useRouter } from "expo-router";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { Platform, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import { useOrientation } from "@/hooks/useOrientation";
|
||||
import { OrientationLock } from "@/packages/expo-screen-orientation";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { ICON_SIZES } from "./constants";
|
||||
import { HEADER_LAYOUT, ICON_SIZES } from "./constants";
|
||||
import DropdownView from "./dropdown/DropdownView";
|
||||
import { PlaybackSpeedScope } from "./utils/playback-speed-settings";
|
||||
import { type AspectRatio } from "./VideoScalingModeSelector";
|
||||
@@ -98,12 +98,13 @@ export const HeaderControls: FC<HeaderControlsProps> = ({
|
||||
left: (settings?.safeAreaInControlsEnabled ?? true) ? insets.left : 0,
|
||||
right:
|
||||
(settings?.safeAreaInControlsEnabled ?? true) ? insets.right : 0,
|
||||
padding: HEADER_LAYOUT.CONTAINER_PADDING,
|
||||
},
|
||||
]}
|
||||
pointerEvents={showControls ? "auto" : "none"}
|
||||
className='flex flex-row justify-between'
|
||||
>
|
||||
<View className='mr-auto p-2' pointerEvents='box-none'>
|
||||
<View className='mr-auto' pointerEvents='box-none'>
|
||||
{!Platform.isTV && (!offline || !mediaSource?.TranscodingUrl) && (
|
||||
<View pointerEvents='auto'>
|
||||
<DropdownView
|
||||
|
||||
@@ -15,3 +15,7 @@ export const ICON_SIZES = {
|
||||
HEADER: 24,
|
||||
CENTER: 50,
|
||||
} as const;
|
||||
|
||||
export const HEADER_LAYOUT = {
|
||||
CONTAINER_PADDING: 8, // p-2 = 8px (matches HeaderControls)
|
||||
} as const;
|
||||
|
||||
@@ -18,7 +18,6 @@ interface PlayerContextProps {
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
tracksReady: boolean;
|
||||
offline: boolean;
|
||||
downloadedItem: DownloadedItem | null;
|
||||
}
|
||||
|
||||
@@ -31,7 +30,6 @@ interface PlayerProviderProps {
|
||||
mediaSource: MediaSourceInfo | null | undefined;
|
||||
isVideoLoaded: boolean;
|
||||
tracksReady: boolean;
|
||||
offline?: boolean;
|
||||
downloadedItem?: DownloadedItem | null;
|
||||
}
|
||||
|
||||
@@ -42,7 +40,6 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
tracksReady,
|
||||
offline = false,
|
||||
downloadedItem = null,
|
||||
}) => {
|
||||
const value = useMemo(
|
||||
@@ -52,18 +49,9 @@ export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
tracksReady,
|
||||
offline,
|
||||
downloadedItem,
|
||||
}),
|
||||
[
|
||||
playerRef,
|
||||
item,
|
||||
mediaSource,
|
||||
isVideoLoaded,
|
||||
tracksReady,
|
||||
offline,
|
||||
downloadedItem,
|
||||
],
|
||||
[playerRef, item, mediaSource, isVideoLoaded, tracksReady, downloadedItem],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
*/
|
||||
|
||||
import { SubtitleDeliveryMethod } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
@@ -57,7 +57,9 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import type { MpvAudioTrack } from "@/modules";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { isImageBasedSubtitle } from "@/utils/jellyfin/subtitleUtils";
|
||||
import type { Track } from "../types";
|
||||
import { usePlayerContext, usePlayerControls } from "./PlayerContext";
|
||||
@@ -75,9 +77,10 @@ export const VideoProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<Track[] | null>(null);
|
||||
const [audioTracks, setAudioTracks] = useState<Track[] | null>(null);
|
||||
|
||||
const { tracksReady, mediaSource, offline, downloadedItem } =
|
||||
usePlayerContext();
|
||||
const { tracksReady, mediaSource, downloadedItem } = usePlayerContext();
|
||||
const playerControls = usePlayerControls();
|
||||
const offline = useOfflineMode();
|
||||
const router = useRouter();
|
||||
|
||||
const { itemId, audioIndex, bitrateValue, subtitleIndex, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Platform, View } from "react-native";
|
||||
import { BITRATES } from "@/components/BitrateSelector";
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
PlatformDropdown,
|
||||
} from "@/components/PlatformDropdown";
|
||||
import { PLAYBACK_SPEEDS } from "@/components/PlaybackSpeedSelector";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useOfflineMode } from "@/providers/OfflineModeProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { usePlayerContext } from "../contexts/PlayerContext";
|
||||
import { useVideoContext } from "../contexts/VideoContext";
|
||||
@@ -38,8 +40,9 @@ const DropdownView = ({
|
||||
const { item, mediaSource } = usePlayerContext();
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
const isOffline = useOfflineMode();
|
||||
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition, offline } =
|
||||
const { subtitleIndex, audioIndex, bitrateValue, playbackPosition } =
|
||||
useLocalSearchParams<{
|
||||
itemId: string;
|
||||
audioIndex: string;
|
||||
@@ -47,15 +50,12 @@ const DropdownView = ({
|
||||
mediaSourceId: string;
|
||||
bitrateValue: string;
|
||||
playbackPosition: string;
|
||||
offline: string;
|
||||
}>();
|
||||
|
||||
// Use ref to track playbackPosition without causing re-renders
|
||||
const playbackPositionRef = useRef(playbackPosition);
|
||||
playbackPositionRef.current = playbackPosition;
|
||||
|
||||
const isOffline = offline === "true";
|
||||
|
||||
// Stabilize IDs to prevent unnecessary recalculations
|
||||
const itemIdRef = useRef(item.Id);
|
||||
const mediaSourceIdRef = useRef(mediaSource?.Id);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useRouter } from "expo-router";
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
useAddToWatchlist,
|
||||
useRemoveFromWatchlist,
|
||||
|
||||
Reference in New Issue
Block a user