diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx
index 1bd33ba2..365a8a90 100644
--- a/app/(auth)/(tabs)/(home)/downloads/index.tsx
+++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx
@@ -7,7 +7,7 @@ import {
} from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router";
import { useAtom } from "jotai";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { toast } from "sonner-native";
@@ -23,7 +23,26 @@ import { type DownloadedItem } from "@/providers/Downloads/types";
import { queueAtom } from "@/utils/atoms/queue";
import { writeToLog } from "@/utils/log";
-export default function page() {
+interface HeaderRightProps {
+ readonly downloadedFiles: DownloadedItem[] | null;
+ readonly onPress: () => void;
+}
+
+function HeaderRight({ downloadedFiles, onPress }: HeaderRightProps) {
+ return (
+
+ f.item) || []} />
+
+ );
+}
+
+function CustomBottomSheetBackdrop(props: Readonly) {
+ return (
+
+ );
+}
+
+const DownloadsPage = () => {
const navigation = useNavigation();
const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom);
@@ -36,6 +55,17 @@ export default function page() {
const router = useRouter();
const bottomSheetModalRef = useRef(null);
+ const handleRemoveQueueItem = useCallback(
+ (queueItemId: string) => {
+ removeProcess(queueItemId);
+ setQueue((prev) => {
+ if (!prev) return [];
+ return prev.filter((i) => i.id !== queueItemId);
+ });
+ },
+ [removeProcess, setQueue],
+ );
+
const [showMigration, setShowMigration] = useState(false);
const migration_20241124 = () => {
@@ -53,9 +83,13 @@ export default function page() {
{
text: t("home.downloads.delete"),
style: "destructive",
- onPress: async () => {
- await deleteAllFiles();
- setShowMigration(false);
+ onPress: () => {
+ deleteAllFiles()
+ .then(() => setShowMigration(false))
+ .catch((error) => {
+ console.error("Failed to delete all files:", error);
+ setShowMigration(false);
+ });
},
},
],
@@ -90,15 +124,21 @@ export default function page() {
}
}, [downloadedFiles]);
+ const headerRightComponent = useMemo(
+ () => (
+ bottomSheetModalRef.current?.present()}
+ />
+ ),
+ [downloadedFiles],
+ );
+
useEffect(() => {
navigation.setOptions({
- headerRight: () => (
-
- f.item) || []} />
-
- ),
+ headerRight: () => headerRightComponent,
});
- }, [downloadedFiles]);
+ }, [headerRightComponent, navigation]);
useEffect(() => {
if (showMigration) {
@@ -145,13 +185,13 @@ export default function page() {
{t("home.downloads.queue_hint")}
- {queue.map((q, index) => (
+ {queue.map((q) => (
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
- key={index}
+ key={q.id}
>
{q.item.Name}
@@ -160,13 +200,7 @@ export default function page() {
{
- removeProcess(q.id);
- setQueue((prev) => {
- if (!prev) return [];
- return [...prev.filter((i) => i.id !== q.id)];
- });
- }}
+ onPress={() => handleRemoveQueueItem(q.id)}
>
@@ -257,13 +291,7 @@ export default function page() {
backgroundStyle={{
backgroundColor: "#171717",
}}
- backdropComponent={(props: BottomSheetBackdropProps) => (
-
- )}
+ backdropComponent={CustomBottomSheetBackdrop}
>
@@ -281,4 +309,6 @@ export default function page() {
>
);
-}
+};
+
+export default DownloadsPage;
diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx
index 91f569df..5550692f 100644
--- a/app/(auth)/(tabs)/(home)/settings.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tsx
@@ -1,7 +1,7 @@
import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai";
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
@@ -25,7 +25,19 @@ import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv";
-export default function settings() {
+interface LogoutButtonProps {
+ readonly onPress: () => void;
+}
+
+function LogoutButton({ onPress }: LogoutButtonProps) {
+ return (
+
+ {t("home.settings.log_out_button")}
+
+ );
+}
+
+const SettingsPage = () => {
const router = useRouter();
const insets = useSafeAreaInsets();
const [_user] = useAtom(userAtom);
@@ -37,22 +49,17 @@ export default function settings() {
successHapticFeedback();
};
+ const headerRightComponent = useMemo(
+ () => logout()} />,
+ [logout],
+ );
+
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
- headerRight: () => (
- {
- logout();
- }}
- >
-
- {t("home.settings.log_out_button")}
-
-
- ),
+ headerRight: () => headerRightComponent,
});
- }, []);
+ }, [headerRightComponent, navigation]);
return (
);
-}
+};
+
+export default SettingsPage;
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
index 3b076235..8b601075 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/jellyseerr/page.tsx
@@ -1,3 +1,18 @@
+import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
+
+interface HeaderRightProps {
+ readonly details: MovieDetails | TvDetails | null | undefined;
+}
+
+function HeaderRight({ details }: HeaderRightProps) {
+ if (!details) return null;
+ return (
+
+
+
+ );
+}
+
import { Ionicons } from "@expo/vector-icons";
import {
BottomSheetBackdrop,
@@ -42,7 +57,6 @@ const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
-import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => {
const insets = useSafeAreaInsets();
@@ -64,7 +78,7 @@ const Page: React.FC = () => {
const [issueType, setIssueType] = useState();
const [issueMessage, setIssueMessage] = useState();
- const [requestBody, _setRequestBody] = useState();
+ const [requestBody, setRequestBody] = useState();
const advancedReqModalRef = useRef(null);
const bottomSheetModalRef = useRef(null);
@@ -115,18 +129,18 @@ const Page: React.FC = () => {
}
}, [jellyseerrApi, details, result, issueType, issueMessage]);
- const setRequestBody = useCallback(
+ const handleSetRequestBody = useCallback(
(body: MediaRequestBody) => {
- _setRequestBody(body);
+ setRequestBody(body);
advancedReqModalRef?.current?.present?.();
},
- [requestBody, _setRequestBody, advancedReqModalRef],
+ [requestBody, setRequestBody, advancedReqModalRef],
);
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
- mediaType: mediaType!,
+ mediaType: mediaType,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
@@ -134,20 +148,12 @@ const Page: React.FC = () => {
};
if (hasAdvancedRequestPermission) {
- setRequestBody(body);
+ handleSetRequestBody(body);
return;
}
requestMedia(mediaTitle, body, refetch);
- }, [
- details,
- result,
- requestMedia,
- hasAdvancedRequestPermission,
- mediaTitle,
- refetch,
- mediaType,
- ]);
+ }, [details, result, requestMedia, hasAdvancedRequestPermission]);
const isAnime = useMemo(
() =>
@@ -156,17 +162,81 @@ const Page: React.FC = () => {
[details],
);
+ const headerRightComponent = useMemo(
+ () => ,
+ [details],
+ );
+
useEffect(() => {
- if (details) {
- navigation.setOptions({
- headerRight: () => (
-
-
-
- ),
- });
+ navigation.setOptions({
+ headerRight: () => headerRightComponent,
+ });
+ }, [headerRightComponent, navigation]);
+
+ const renderActionButton = () => {
+ if (isLoading || isFetching) {
+ return (
+
+ );
}
- }, [details]);
+
+ if (canRequest) {
+ return (
+
+ );
+ }
+
+ if (details?.mediaInfo?.jellyfinMediaId) {
+ return (
+
+ {!Platform.isTV && (
+
+ )}
+
+
+ );
+ }
+
+ return null;
+ };
return (
{
g.name) || []} />
- {isLoading || isFetching ? (
-
- ) : canRequest ? (
-
- ) : (
- details?.mediaInfo?.jellyfinMediaId && (
-
- {!Platform.isTV && (
-
- )}
-
-
- )
- )}
+ {renderActionButton()}
@@ -318,7 +326,7 @@ const Page: React.FC = () => {
details={details as TvDetails}
refetch={refetch}
hasAdvancedRequest={hasAdvancedRequestPermission}
- onAdvancedRequest={(data) => setRequestBody(data)}
+ onAdvancedRequest={(data) => handleSetRequestBody(data)}
/>
)}
{
type={mediaType}
isAnime={isAnime}
onRequested={() => {
- _setRequestBody(undefined);
+ setRequestBody(undefined);
advancedReqModalRef?.current?.close();
refetch();
}}
- onDismiss={() => _setRequestBody(undefined)}
+ onDismiss={() => setRequestBody(undefined)}
/>
{!Platform.isTV && (
// This is till it's fixed because the menu isn't selectable on TV
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
index 78cad4a3..968319ba 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/series/[id].tsx
@@ -19,9 +19,49 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
-const page: React.FC = () => {
- const navigation = useNavigation();
+function MissingDownloadIcon() {
+ return ;
+}
+
+function DownloadedIcon() {
+ return ;
+}
+
+interface SeriesHeaderRightProps {
+ readonly isLoading: boolean;
+ readonly item: any;
+ readonly allEpisodes: any[];
+}
+
+function SeriesHeaderRight({
+ isLoading,
+ item,
+ allEpisodes,
+}: SeriesHeaderRightProps) {
const { t } = useTranslation();
+
+ if (isLoading || !item || !allEpisodes || allEpisodes.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {!Platform.isTV && (
+
+ )}
+
+ );
+}
+
+const SeriesPage: React.FC = () => {
+ const navigation = useNavigation();
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
@@ -85,36 +125,22 @@ const page: React.FC = () => {
enabled: !!api && !!user?.Id && !!item?.Id,
});
+ const headerRightComponent = useMemo(
+ () => (
+
+ ),
+ [isLoading, item, allEpisodes],
+ );
+
useEffect(() => {
navigation.setOptions({
- headerRight: () =>
- !isLoading &&
- item &&
- allEpisodes &&
- allEpisodes.length > 0 && (
-
-
- {!Platform.isTV && (
- (
-
- )}
- DownloadedIconComponent={() => (
-
- )}
- />
- )}
-
- ),
+ headerRight: () => headerRightComponent,
});
- }, [allEpisodes, isLoading, item]);
+ }, [headerRightComponent, navigation]);
if (!item || !backdropUrl) return null;
@@ -158,4 +184,4 @@ const page: React.FC = () => {
);
};
-export default page;
+export default SeriesPage;
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
index b7681174..97eda95d 100644
--- a/app/(auth)/player/direct-player.tsx
+++ b/app/(auth)/player/direct-player.tsx
@@ -41,7 +41,7 @@ import { writeToLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time";
-export default function page() {
+export default function Page() {
const videoRef = useRef(null);
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
@@ -49,7 +49,7 @@ export default function page() {
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
- const [showControls, _setShowControls] = useState(true);
+ const [showControls, setShowControls] = useState(true);
const [aspectRatio, setAspectRatio] = useState<
"default" | "16:9" | "4:3" | "1:1" | "21:9"
>("default");
@@ -75,10 +75,13 @@ export default function page() {
const lightHapticFeedback = useHaptic("light");
- const setShowControls = useCallback((show: boolean) => {
- _setShowControls(show);
- lightHapticFeedback();
- }, []);
+ const setShowControlsWithHaptic = useCallback(
+ (show: boolean) => {
+ setShowControls(show);
+ lightHapticFeedback();
+ },
+ [lightHapticFeedback, setShowControls],
+ );
const {
itemId,
@@ -138,7 +141,7 @@ export default function page() {
if (offline && !Platform.isTV) {
const data = downloadUtils.getDownloadedItemById(itemId);
if (data) {
- fetchedItem = data.item as BaseItemDto;
+ fetchedItem = data.item;
setDownloadedItem(data);
}
} else {
@@ -173,64 +176,82 @@ export default function page() {
isError: false,
});
+ const createOfflineStream = useCallback(() => {
+ if (!downloadedItem?.mediaSource || !item) return null;
+
+ return {
+ mediaSource: downloadedItem.mediaSource,
+ sessionId: "",
+ url: downloadedItem.videoFilePath,
+ };
+ }, [downloadedItem, item]);
+
+ const validateStreamingRequirements = useCallback(() => {
+ if (!api) {
+ console.warn("API not available for streaming");
+ return false;
+ }
+ if (!user?.Id) {
+ console.warn("User not authenticated for streaming");
+ return false;
+ }
+ return true;
+ }, [api, user?.Id]);
+
+ const fetchOnlineStream = useCallback(async () => {
+ if (!validateStreamingRequirements() || !user?.Id) return null;
+
+ const native = generateDeviceProfile();
+ const transcoding = generateDeviceProfile({ transcode: true });
+
+ const res = await getStreamUrl({
+ api,
+ item,
+ startTimeTicks: getInitialPlaybackTicks(),
+ userId: user.Id,
+ audioStreamIndex: audioIndex,
+ maxStreamingBitrate: bitrateValue,
+ mediaSourceId: mediaSourceId,
+ subtitleStreamIndex: subtitleIndex,
+ deviceProfile: bitrateValue ? transcoding : native,
+ });
+
+ if (!res) return null;
+
+ const { mediaSource, sessionId, url } = res;
+ if (!sessionId || !mediaSource || !url) {
+ Alert.alert(t("player.error"), t("player.failed_to_get_stream_url"));
+ return null;
+ }
+
+ return { mediaSource, sessionId, url };
+ }, [
+ validateStreamingRequirements,
+ api,
+ item,
+ getInitialPlaybackTicks,
+ user?.Id,
+ audioIndex,
+ bitrateValue,
+ mediaSourceId,
+ subtitleIndex,
+ t,
+ ]);
+
useEffect(() => {
const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
try {
- // Don't attempt to fetch stream data if item is not available
if (!item?.Id) {
console.log("Item not loaded yet, skipping stream data fetch");
setStreamStatus({ isLoading: false, isError: false });
return;
}
- let result: Stream | null = null;
- if (offline && downloadedItem && downloadedItem.mediaSource) {
- const url = downloadedItem.videoFilePath;
- if (item) {
- result = {
- mediaSource: downloadedItem.mediaSource,
- sessionId: "",
- url: url,
- };
- }
- } else {
- // Validate required parameters before calling getStreamUrl
- if (!api) {
- console.warn("API not available for streaming");
- setStreamStatus({ isLoading: false, isError: true });
- return;
- }
- if (!user?.Id) {
- console.warn("User not authenticated for streaming");
- setStreamStatus({ isLoading: false, isError: true });
- return;
- }
+ const result = offline
+ ? createOfflineStream()
+ : await fetchOnlineStream();
- const native = generateDeviceProfile();
- const transcoding = generateDeviceProfile({ transcode: true });
- const res = await getStreamUrl({
- api,
- item,
- startTimeTicks: getInitialPlaybackTicks(),
- userId: user.Id,
- audioStreamIndex: audioIndex,
- maxStreamingBitrate: bitrateValue,
- mediaSourceId: mediaSourceId,
- subtitleStreamIndex: subtitleIndex,
- deviceProfile: bitrateValue ? transcoding : native,
- });
- if (!res) return;
- const { mediaSource, sessionId, url } = res;
- if (!sessionId || !mediaSource || !url) {
- Alert.alert(
- t("player.error"),
- t("player.failed_to_get_stream_url"),
- );
- return;
- }
- result = { mediaSource, sessionId, url };
- }
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
} catch (error) {
@@ -247,6 +268,9 @@ export default function page() {
item,
user?.Id,
downloadedItem,
+ offline,
+ createOfflineStream,
+ fetchOnlineStream,
]);
useEffect(() => {
@@ -315,8 +339,8 @@ export default function page() {
if (!stream) return;
return {
itemId: item?.Id!,
- audioStreamIndex: audioIndex ? audioIndex : undefined,
- subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ audioStreamIndex: audioIndex || undefined,
+ subtitleStreamIndex: subtitleIndex || undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()),
isPaused: !isPlaying,
@@ -472,15 +496,46 @@ export default function page() {
}
}, []);
+ const handleTogglePlay = useCallback(() => {
+ togglePlay().catch((error) => console.error("Error toggling play:", error));
+ }, [togglePlay]);
+
+ const handleToggleMute = useCallback(() => {
+ toggleMuteCb().catch((error) =>
+ console.error("Error toggling mute:", error),
+ );
+ }, [toggleMuteCb]);
+
+ const handleVolumeUp = useCallback(() => {
+ volumeUpCb().catch((error) =>
+ console.error("Error increasing volume:", error),
+ );
+ }, [volumeUpCb]);
+
+ const handleVolumeDown = useCallback(() => {
+ volumeDownCb().catch((error) =>
+ console.error("Error decreasing volume:", error),
+ );
+ }, [volumeDownCb]);
+
+ const handleSetVolume = useCallback(
+ (volume: number) => {
+ setVolumeCb(volume).catch((error) =>
+ console.error("Error setting volume:", error),
+ );
+ },
+ [setVolumeCb],
+ );
+
useWebSocket({
isPlaying: isPlaying,
- togglePlay: togglePlay,
+ togglePlay: handleTogglePlay,
stopPlayback: stop,
offline,
- toggleMute: toggleMuteCb,
- volumeUp: volumeUpCb,
- volumeDown: volumeDownCb,
- setVolume: setVolumeCb,
+ toggleMute: handleToggleMute,
+ volumeUp: handleVolumeUp,
+ volumeDown: handleVolumeDown,
+ setVolume: handleSetVolume,
});
const onPlaybackStateChanged = useCallback(
@@ -701,7 +756,6 @@ export default function page() {
) {
return (
diff --git a/app/_layout.tsx b/app/_layout.tsx
index c94f540e..cc1b4b7b 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,7 +1,7 @@
import "@/augmentations";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
-import { Platform } from "react-native";
+import { Appearance, AppState, Platform } from "react-native";
import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
@@ -9,6 +9,7 @@ import {
getOrSetDeviceId,
getTokenFromStorage,
JellyfinProvider,
+ userAtom,
} from "@/providers/JellyfinProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider";
@@ -45,10 +46,9 @@ import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import * as TaskManager from "expo-task-manager";
-import { Provider as JotaiProvider } from "jotai";
+import { Provider as JotaiProvider, useAtom } from "jotai";
import { useEffect, useRef, useState } from "react";
import { I18nextProvider } from "react-i18next";
-import { Appearance, AppState } from "react-native";
import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
@@ -60,9 +60,7 @@ import type {
NotificationResponse,
} from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
-import { useAtom } from "jotai";
import { Toaster } from "sonner-native";
-import { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
if (!Platform.isTV) {
@@ -281,7 +279,7 @@ function Layout() {
return;
}
- if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
+ if (!Platform.isTV && user?.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
diff --git a/components/Button.tsx b/components/Button.tsx
index bbd0082a..262dca28 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -56,7 +56,7 @@ export const Button: React.FC> = ({
useNativeDriver: true,
}).start();
- const colorClasses = useMemo(() => {
+ const getColorClasses = (color: string, focused: boolean) => {
switch (color) {
case "purple":
return focused
@@ -68,12 +68,38 @@ export const Button: React.FC> = ({
return "bg-neutral-900";
case "transparent":
return "bg-transparent";
+ default:
+ return "bg-purple-600 border border-purple-700";
}
- }, [color, focused]);
+ };
+
+ const colorClasses = useMemo(
+ () => getColorClasses(color, focused),
+ [color, focused],
+ );
const lightHapticFeedback = useHaptic("light");
- return Platform.isTV ? (
+ const handlePress = () => {
+ if (!loading && !disabled && onPress) {
+ onPress();
+ lightHapticFeedback();
+ }
+ };
+
+ const getTextClasses = () => {
+ const baseClasses = "text-white font-bold text-base";
+ const disabledClass = disabled ? " text-gray-300" : "";
+ const rightMargin = iconRight ? " mr-2" : "";
+ const leftMargin = iconLeft ? " ml-2" : "";
+ return `${baseClasses}${disabledClass} ${textClassName}${rightMargin}${leftMargin}`;
+ };
+
+ const getJustifyClass = () => {
+ return justify === "between" ? "justify-between" : "justify-center";
+ };
+
+ const renderTVButton = () => (
> = ({
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.9 : 0,
shadowRadius: focused ? 18 : 0,
- elevation: focused ? 12 : 0, // Android glow
+ elevation: focused ? 12 : 0,
}}
>
> = ({
- ) : (
+ );
+
+ const renderTouchButton = () => (
> = ({
${colorClasses}
${className}
`}
- onPress={() => {
- if (!loading && !disabled && onPress) {
- onPress();
- lightHapticFeedback();
- }
- }}
+ onPress={handlePress}
disabled={disabled || loading}
{...props}
>
@@ -128,25 +151,15 @@ export const Button: React.FC> = ({
) : (
- {iconLeft ? iconLeft : }
-
- {children}
-
- {iconRight ? iconRight : }
+ {iconLeft || }
+ {children}
+ {iconRight || }
)}
);
+
+ return Platform.isTV ? renderTVButton() : renderTouchButton();
};
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index 3f4ca141..8d7788b3 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -6,8 +6,9 @@ import { useAtom, useAtomValue } from "jotai";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Alert, TouchableOpacity, View } from "react-native";
-import CastContext, {
+import {
CastButton,
+ CastContext,
PlayServicesState,
useMediaStatus,
useRemoteMediaClient,
@@ -44,6 +45,63 @@ interface Props extends React.ComponentProps {
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
+// Helper function to create media metadata for Chromecast
+const createMediaMetadata = (item: BaseItemDto, api: any) => {
+ if (item.Type === "Episode") {
+ return {
+ type: "tvShow" as const,
+ title: item.Name || "",
+ episodeNumber: item.IndexNumber || 0,
+ seasonNumber: item.ParentIndexNumber || 0,
+ seriesTitle: item.SeriesName || "",
+ images: [
+ {
+ url: getParentBackdropImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 2000,
+ })!,
+ },
+ ],
+ };
+ }
+
+ if (item.Type === "Movie") {
+ return {
+ type: "movie" as const,
+ title: item.Name || "",
+ subtitle: item.Overview || "",
+ images: [
+ {
+ url: getPrimaryImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 2000,
+ })!,
+ },
+ ],
+ };
+ }
+
+ return {
+ type: "generic" as const,
+ title: item.Name || "",
+ subtitle: item.Overview || "",
+ images: [
+ {
+ url: getPrimaryImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 2000,
+ })!,
+ },
+ ],
+ };
+};
+
export const PlayButton: React.FC = ({
item,
selectedOptions,
@@ -80,12 +138,86 @@ export const PlayButton: React.FC = ({
[router, isOffline],
);
+ const handleChromecast = useCallback(
+ async (params: {
+ item: BaseItemDto;
+ api: any;
+ user: any;
+ selectedOptions: SelectedOptions;
+ client: any;
+ t: any;
+ settings: any;
+ isOpeningCurrentlyPlayingMedia: boolean;
+ }) => {
+ const {
+ item,
+ api,
+ user,
+ selectedOptions,
+ client,
+ t,
+ settings,
+ isOpeningCurrentlyPlayingMedia,
+ } = params;
+ const enableH265 = settings.enableH265ForChromecast;
+ if (!api) {
+ console.warn("API not available for Chromecast streaming");
+ Alert.alert(t("player.client_error"), t("player.missing_parameters"));
+ return;
+ }
+ if (!user?.Id) {
+ console.warn("User not authenticated for Chromecast streaming");
+ Alert.alert(t("player.client_error"), t("player.missing_parameters"));
+ return;
+ }
+ if (!item?.Id) {
+ console.warn("Item not available for Chromecast streaming");
+ Alert.alert(t("player.client_error"), t("player.missing_parameters"));
+ return;
+ }
+ try {
+ const data = await getStreamUrl({
+ api,
+ item,
+ deviceProfile: enableH265 ? chromecasth265 : chromecast,
+ startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
+ userId: user.Id,
+ audioStreamIndex: selectedOptions.audioIndex,
+ maxStreamingBitrate: selectedOptions.bitrate?.value,
+ mediaSourceId: selectedOptions.mediaSource?.Id,
+ subtitleStreamIndex: selectedOptions.subtitleIndex,
+ });
+ if (!data?.url) {
+ console.warn("No URL returned from getStreamUrl", data);
+ Alert.alert(
+ t("player.client_error"),
+ t("player.could_not_create_stream_for_chromecast"),
+ );
+ return;
+ }
+ client
+ .loadMedia({
+ mediaInfo: {
+ contentUrl: data?.url,
+ contentType: "video/mp4",
+ metadata: createMediaMetadata(item, api),
+ },
+ startTime: 0,
+ })
+ .then(() => {
+ if (isOpeningCurrentlyPlayingMedia) return;
+ CastContext.showExpandedControls();
+ });
+ } catch (e) {
+ console.log(e);
+ }
+ },
+ [],
+ );
+
const onPress = useCallback(async () => {
- console.log("onPress");
if (!item) return;
-
lightHapticFeedback();
-
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
@@ -95,14 +227,11 @@ export const PlayButton: React.FC = ({
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
});
-
const queryString = queryParams.toString();
-
if (!client) {
goToPlayer(queryString);
return;
}
-
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
@@ -115,137 +244,23 @@ export const PlayButton: React.FC = ({
const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
const isOpeningCurrentlyPlayingMedia =
currentTitle && currentTitle === item?.Name;
-
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then(async (state) => {
if (state && state !== PlayServicesState.SUCCESS) {
CastContext.showPlayServicesErrorDialog(state);
} else {
- // Check if user wants H265 for Chromecast
- const enableH265 = settings.enableH265ForChromecast;
-
- // Validate required parameters before calling getStreamUrl
- if (!api) {
- console.warn("API not available for Chromecast streaming");
- Alert.alert(
- t("player.client_error"),
- t("player.missing_parameters"),
- );
- return;
- }
- if (!user?.Id) {
- console.warn(
- "User not authenticated for Chromecast streaming",
- );
- Alert.alert(
- t("player.client_error"),
- t("player.missing_parameters"),
- );
- return;
- }
- if (!item?.Id) {
- console.warn("Item not available for Chromecast streaming");
- Alert.alert(
- t("player.client_error"),
- t("player.missing_parameters"),
- );
- return;
- }
-
- // Get a new URL with the Chromecast device profile
- try {
- const data = await getStreamUrl({
- api,
- item,
- deviceProfile: enableH265 ? chromecasth265 : chromecast,
- startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
- userId: user.Id,
- audioStreamIndex: selectedOptions.audioIndex,
- maxStreamingBitrate: selectedOptions.bitrate?.value,
- mediaSourceId: selectedOptions.mediaSource?.Id,
- subtitleStreamIndex: selectedOptions.subtitleIndex,
- });
-
- console.log("URL: ", data?.url, enableH265);
-
- if (!data?.url) {
- console.warn("No URL returned from getStreamUrl", data);
- Alert.alert(
- t("player.client_error"),
- t("player.could_not_create_stream_for_chromecast"),
- );
- return;
- }
-
- client
- .loadMedia({
- mediaInfo: {
- contentUrl: data?.url,
- contentType: "video/mp4",
- metadata:
- item.Type === "Episode"
- ? {
- type: "tvShow",
- title: item.Name || "",
- episodeNumber: item.IndexNumber || 0,
- seasonNumber: item.ParentIndexNumber || 0,
- seriesTitle: item.SeriesName || "",
- images: [
- {
- url: getParentBackdropImageUrl({
- api,
- item,
- quality: 90,
- width: 2000,
- })!,
- },
- ],
- }
- : item.Type === "Movie"
- ? {
- type: "movie",
- title: item.Name || "",
- subtitle: item.Overview || "",
- images: [
- {
- url: getPrimaryImageUrl({
- api,
- item,
- quality: 90,
- width: 2000,
- })!,
- },
- ],
- }
- : {
- type: "generic",
- title: item.Name || "",
- subtitle: item.Overview || "",
- images: [
- {
- url: getPrimaryImageUrl({
- api,
- item,
- quality: 90,
- width: 2000,
- })!,
- },
- ],
- },
- },
- startTime: 0,
- })
- .then(() => {
- // state is already set when reopening current media, so skip it here.
- if (isOpeningCurrentlyPlayingMedia) {
- return;
- }
- CastContext.showExpandedControls();
- });
- } catch (e) {
- console.log(e);
- }
+ await handleChromecast({
+ item,
+ api,
+ user,
+ selectedOptions,
+ client,
+ t,
+ settings,
+ isOpeningCurrentlyPlayingMedia:
+ !!isOpeningCurrentlyPlayingMedia,
+ });
}
});
break;
@@ -267,10 +282,14 @@ export const PlayButton: React.FC = ({
showActionSheetWithOptions,
mediaStatus,
selectedOptions,
+ lightHapticFeedback,
+ goToPlayer,
+ isOffline,
+ handleChromecast,
]);
const derivedTargetWidth = useDerivedValue(() => {
- if (!item || !item.RunTimeTicks) return 0;
+ if (!item?.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
diff --git a/components/PlayButton.tv.tsx b/components/PlayButton.tv.tsx
index b4fa45a9..9afff18f 100644
--- a/components/PlayButton.tv.tsx
+++ b/components/PlayButton.tv.tsx
@@ -70,11 +70,10 @@ export const PlayButton: React.FC = ({
const queryString = queryParams.toString();
goToPlayer(queryString);
- return;
};
const derivedTargetWidth = useDerivedValue(() => {
- if (!item || !item.RunTimeTicks) return 0;
+ if (!item?.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
diff --git a/components/common/InfiniteHorizontalScroll.tsx b/components/common/InfiniteHorizontalScroll.tsx
index 1f24d575..0823ad17 100644
--- a/components/common/InfiniteHorizontalScroll.tsx
+++ b/components/common/InfiniteHorizontalScroll.tsx
@@ -45,7 +45,7 @@ export function InfiniteHorizontalScroll({
loading = false,
height = 164,
...props
-}: HorizontalScrollProps): React.ReactElement {
+}: Readonly): React.ReactElement {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
diff --git a/components/common/JellyseerrItemRouter.tsx b/components/common/JellyseerrItemRouter.tsx
index 8a0b059a..45c8727c 100644
--- a/components/common/JellyseerrItemRouter.tsx
+++ b/components/common/JellyseerrItemRouter.tsx
@@ -16,6 +16,7 @@ import type {
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
+import { getCurrentTab } from "@/utils/navigation";
interface Props extends TouchableOpacityProps {
result?: MovieResult | TvResult | MovieDetails | TvDetails | PersonCreditCast;
@@ -40,7 +41,8 @@ export const TouchableJellyseerrRouter: React.FC> = ({
const segments = useSegments();
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();
- const from = (segments as string[])[2] || "(home)";
+ // Some segment arrays may have fewer than 3 elements; fall back to home tab.
+ const from = getCurrentTab(segments as string[]);
const autoApprove = useMemo(() => {
return (
@@ -69,14 +71,13 @@ export const TouchableJellyseerrRouter: React.FC> = ({
router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
- // @ts-expect-error
params: {
- ...result,
mediaTitle,
- releaseYear,
- canRequest: canRequest.toString(),
+ releaseYear: releaseYear.toString(),
+ canRequest: canRequest ? "true" : "false",
posterSrc,
- mediaType,
+ mediaType: mediaType.toString(),
+ id: result.id?.toString?.() ?? undefined,
},
});
}}
diff --git a/components/common/Text.tsx b/components/common/Text.tsx
index 739177d7..1566f44f 100644
--- a/components/common/Text.tsx
+++ b/components/common/Text.tsx
@@ -1,5 +1,5 @@
import { Platform, Text as RNText, type TextProps } from "react-native";
-export function Text(props: TextProps) {
+export function Text(props: Readonly) {
const { style, ...otherProps } = props;
if (Platform.isTV)
return (
diff --git a/components/common/TouchableItemRouter.tsx b/components/common/TouchableItemRouter.tsx
index e95fd5e7..6329b4a9 100644
--- a/components/common/TouchableItemRouter.tsx
+++ b/components/common/TouchableItemRouter.tsx
@@ -5,6 +5,7 @@ import { type PropsWithChildren, useCallback } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import { useFavorite } from "@/hooks/useFavorite";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
+import { getCurrentTab } from "@/utils/navigation";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
@@ -43,48 +44,6 @@ export const itemRouter = (item: BaseItemDto, from: string) => {
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
};
-export const getItemNavigation = (item: BaseItemDto, _from: string) => {
- if ("CollectionType" in item && item.CollectionType === "livetv") {
- return {
- pathname: "/livetv" as const,
- };
- }
-
- if (item.Type === "Series") {
- return {
- pathname: "/series/[id]" as const,
- params: { id: item.Id! },
- };
- }
-
- if (item.Type === "Person") {
- return {
- pathname: "/persons/[personId]" as const,
- params: { personId: item.Id! },
- };
- }
-
- if (item.Type === "BoxSet" || item.Type === "UserView") {
- return {
- pathname: "/collections/[collectionId]" as const,
- params: { collectionId: item.Id! },
- };
- }
-
- if (item.Type === "CollectionFolder" || item.Type === "Playlist") {
- return {
- pathname: "/[libraryId]" as const,
- params: { libraryId: item.Id! },
- };
- }
-
- // Default case - items page
- return {
- pathname: "/items/page" as const,
- params: { id: item.Id! },
- };
-};
-
export const TouchableItemRouter: React.FC> = ({
item,
isOffline = false,
@@ -97,7 +56,7 @@ export const TouchableItemRouter: React.FC> = ({
const markAsPlayedStatus = useMarkAsPlayed([item]);
const { isFavorite, toggleFavorite } = useFavorite(item);
- const from = (segments as string[])[2] || "(home)";
+ const from = getCurrentTab(segments as string[]);
const showActionSheet = useCallback(() => {
if (
@@ -143,15 +102,11 @@ export const TouchableItemRouter: React.FC> = ({
{
+ let url = itemRouter(item, from);
if (isOffline) {
- // For offline mode, we still need to use query params
- const url = `${itemRouter(item, from)}&offline=true`;
- router.push(url as any);
- return;
+ url += `&offline=true`;
}
-
- const navigation = getItemNavigation(item, from);
- router.push(navigation as any);
+ router.push(url);
}}
{...props}
>
diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx
index 1431fce3..0ebcf105 100644
--- a/components/home/LargeMovieCarousel.tsx
+++ b/components/home/LargeMovieCarousel.tsx
@@ -21,7 +21,8 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
-import { getItemNavigation } from "../common/TouchableItemRouter";
+import { getCurrentTab } from "@/utils/navigation";
+import { itemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {}
@@ -145,16 +146,16 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return getLogoImageUrlById({ api, item, height: 100 });
}, [item]);
- const segments = useSegments();
- const from = (segments as string[])[2] || "(home)";
+ const segments = useSegments() as string[];
+ const from = getCurrentTab(segments);
const opacity = useSharedValue(1);
const handleRoute = useCallback(() => {
if (!from) return;
+ const url = itemRouter(item, from);
lightHapticFeedback();
- const navigation = getItemNavigation(item, from);
- router.push(navigation as any);
+ if (url) router.push(url as any);
}, [item, from]);
const tap = Gesture.Tap()
diff --git a/components/inputs/PinInput.tsx b/components/inputs/PinInput.tsx
index abc32365..8b936460 100644
--- a/components/inputs/PinInput.tsx
+++ b/components/inputs/PinInput.tsx
@@ -4,18 +4,16 @@ import {
type StyleProp,
StyleSheet,
Text,
- type TextInputProps,
View,
type ViewStyle,
} from "react-native";
-interface PinInputProps
- extends Omit {
- value: string;
- onChangeText: (text: string) => void;
- length?: number;
- autoFocus?: boolean;
- style?: StyleProp;
+interface PinInputProps {
+ readonly length: number;
+ readonly value: string;
+ readonly onChangeText: (text: string) => void;
+ readonly style?: StyleProp;
+ readonly autoFocus?: boolean;
}
export interface PinInputRef {
@@ -65,7 +63,7 @@ const PinInputComponent = React.forwardRef(
.fill(0)
.map((_, i) => (
| React.ReactElement | null | undefined;
-interface Props {
+const ItemSeparator = () => ;
+
+interface ParallaxSlideShowProps {
data: T[];
images: string[];
logo?: React.ReactElement;
@@ -27,7 +29,7 @@ interface Props {
listHeader: string;
renderItem: (item: T, index: number) => Render;
keyExtractor: (item: T) => string;
- onEndReached?: (() => void) | null | undefined;
+ onEndReached?: (() => void) | null;
}
const ParallaxSlideShow = ({
@@ -40,7 +42,7 @@ const ParallaxSlideShow = ({
renderItem,
keyExtractor,
onEndReached,
-}: PropsWithChildren & ViewProps>) => {
+}: PropsWithChildren & ViewProps>) => {
const insets = useSafeAreaInsets();
const [currentIndex, setCurrentIndex] = useState(0);
@@ -66,15 +68,21 @@ const ParallaxSlideShow = ({
[fadeAnim],
);
+ const handleAnimationComplete = useCallback(() => {
+ fadeAnim.setValue(0);
+ setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
+ }, [fadeAnim, images?.length, setCurrentIndex]);
+
+ const createSlideSequence = useCallback(() => {
+ return Animated.sequence([enterAnimation(), exitAnimation()]);
+ }, [enterAnimation, exitAnimation]);
+
useEffect(() => {
if (images?.length) {
enterAnimation().start();
const intervalId = setInterval(() => {
- Animated.sequence([enterAnimation(), exitAnimation()]).start(() => {
- fadeAnim.setValue(0);
- setCurrentIndex((prevIndex) => (prevIndex + 1) % images?.length);
- });
+ createSlideSequence().start(handleAnimationComplete);
}, BACKDROP_DURATION);
return () => {
@@ -88,6 +96,8 @@ const ParallaxSlideShow = ({
exitAnimation,
setCurrentIndex,
currentIndex,
+ createSlideSequence,
+ handleAnimationComplete,
]);
return (
@@ -139,12 +149,20 @@ const ParallaxSlideShow = ({
}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
- //@ts-expect-error
- renderItem={({ item, index }) => renderItem(item, index)}
+ renderItem={({ item, index }) => {
+ const rendered = renderItem(item as any, index);
+ if (!rendered) return null;
+ // If the result is a component type, instantiate it
+ if (typeof rendered === "function") {
+ const Comp: any = rendered;
+ return ;
+ }
+ return rendered as React.ReactElement;
+ }}
keyExtractor={keyExtractor}
numColumns={3}
estimatedItemSize={214}
- ItemSeparatorComponent={() => }
+ ItemSeparatorComponent={ItemSeparator}
/>
diff --git a/components/jellyseerr/PersonPoster.tsx b/components/jellyseerr/PersonPoster.tsx
index 70e44d35..bb64cbd4 100644
--- a/components/jellyseerr/PersonPoster.tsx
+++ b/components/jellyseerr/PersonPoster.tsx
@@ -4,6 +4,7 @@ import { TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import Poster from "@/components/posters/Poster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
+import { getCurrentTab } from "@/utils/navigation";
interface Props {
id: string;
@@ -21,8 +22,8 @@ const PersonPoster: React.FC = ({
}) => {
const { jellyseerrApi } = useJellyseerr();
const router = useRouter();
- const segments = useSegments();
- const from = (segments as string[])[2] || "(home)";
+ const segments = useSegments() as string[];
+ const from = getCurrentTab(segments);
if (from === "(home)" || from === "(search)" || from === "(libraries)")
return (
diff --git a/components/jellyseerr/discover/CompanySlide.tsx b/components/jellyseerr/discover/CompanySlide.tsx
index 9643f48e..93654403 100644
--- a/components/jellyseerr/discover/CompanySlide.tsx
+++ b/components/jellyseerr/discover/CompanySlide.tsx
@@ -10,13 +10,14 @@ import {
type Network,
} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import type { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
+import { getCurrentTab } from "@/utils/navigation";
const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps
> = ({ slide, data, ...props }) => {
- const segments = useSegments();
+ const segments = useSegments() as string[];
const { jellyseerrApi } = useJellyseerr();
- const from = (segments as string[])[2] || "(home)";
+ const from = getCurrentTab(segments);
const navigate = useCallback(
({ id, image, name }: Network | Studio) =>
diff --git a/components/jellyseerr/discover/GenericSlideCard.tsx b/components/jellyseerr/discover/GenericSlideCard.tsx
index 5ee68dd9..aaef6b95 100644
--- a/components/jellyseerr/discover/GenericSlideCard.tsx
+++ b/components/jellyseerr/discover/GenericSlideCard.tsx
@@ -34,37 +34,34 @@ const GenericSlideCard: React.FC<
contentFit = "contain",
...props
}) => (
- <>
-
-
-
- {title && (
-
-
- {title}
-
-
- )}
-
-
- >
+
+
+
+ {title && (
+
+
+ {title}
+
+
+ )}
+
+
);
-
export default GenericSlideCard;
diff --git a/components/jellyseerr/discover/GenreSlide.tsx b/components/jellyseerr/discover/GenreSlide.tsx
index 8edaf4c3..eab86204 100644
--- a/components/jellyseerr/discover/GenreSlide.tsx
+++ b/components/jellyseerr/discover/GenreSlide.tsx
@@ -9,11 +9,12 @@ import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
+import { getCurrentTab } from "@/utils/navigation";
const GenreSlide: React.FC = ({ slide, ...props }) => {
- const segments = useSegments();
+ const segments = useSegments() as string[];
const { jellyseerrApi } = useJellyseerr();
- const from = (segments as string[])[2] || "(home)";
+ const from = getCurrentTab(segments);
const navigate = useCallback(
(genre: GenreSliderItem) =>
diff --git a/components/jellyseerr/discover/RecentRequestsSlide.tsx b/components/jellyseerr/discover/RecentRequestsSlide.tsx
index 71b5fc21..8f0c7830 100644
--- a/components/jellyseerr/discover/RecentRequestsSlide.tsx
+++ b/components/jellyseerr/discover/RecentRequestsSlide.tsx
@@ -6,16 +6,8 @@ import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
-import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
-type ExtendedMediaRequest = NonFunctionProperties & {
- profileName: string;
- canRemove: boolean;
-};
-
-const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
- request,
-}) => {
+const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
const { jellyseerrApi } = useJellyseerr();
const { data: details } = useQuery({
@@ -74,17 +66,9 @@ const RecentRequestsSlide: React.FC = ({
({
- ...item,
- profileName: item.profileName ?? "Unknown",
- canRemove: Boolean(item.canRemove),
- })) as ExtendedMediaRequest[]
- }
+ data={requests.results as MediaRequest[]}
keyExtractor={(item) => item.id.toString()}
- renderItem={(item: ExtendedMediaRequest) => (
-
- )}
+ renderItem={(item: MediaRequest) => }
/>
)
);
diff --git a/components/jellyseerr/discover/Slide.tsx b/components/jellyseerr/discover/Slide.tsx
index 7352fb6c..ca136d94 100644
--- a/components/jellyseerr/discover/Slide.tsx
+++ b/components/jellyseerr/discover/Slide.tsx
@@ -20,7 +20,7 @@ interface Props extends SlideProps {
index: number,
) => React.ComponentType | React.ReactElement | null | undefined;
keyExtractor: (item: T) => string;
- onEndReached?: (() => void) | null | undefined;
+ onEndReached?: (() => void) | null;
}
const Slide = ({
@@ -41,7 +41,7 @@ const Slide = ({
horizontal
contentContainerStyle={{
paddingHorizontal: 16,
- ...(contentContainerStyle ? contentContainerStyle : {}),
+ ...(contentContainerStyle ?? {}),
}}
showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor}
@@ -49,10 +49,16 @@ const Slide = ({
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}
- //@ts-expect-error
- renderItem={({ item, index }) =>
- item ? renderItem(item, index) : null
- }
+ renderItem={({ item, index }) => {
+ if (!item) return null;
+ const rendered = renderItem(item, index);
+ if (!rendered) return null;
+ if (typeof rendered === "function") {
+ const Comp: any = rendered;
+ return ;
+ }
+ return rendered;
+ }}
/>
);
diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx
index d7b1fcdb..f23b5a45 100644
--- a/components/posters/JellyseerrPoster.tsx
+++ b/components/posters/JellyseerrPoster.tsx
@@ -16,7 +16,10 @@ import JellyseerrStatusIcon from "@/components/jellyseerr/JellyseerrStatusIcon";
import { Colors } from "@/constants/Colors";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
-import { MediaStatus } from "@/utils/jellyseerr/server/constants/media";
+import {
+ MediaStatus,
+ MediaType,
+} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { DownloadingItem } from "@/utils/jellyseerr/server/lib/downloadtracker";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
@@ -123,11 +126,11 @@ const JellyseerrPoster: React.FC = ({
return (
= ({
/>
diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx
index 037206e9..23887ce3 100644
--- a/components/series/CastAndCrew.tsx
+++ b/components/series/CastAndCrew.tsx
@@ -10,8 +10,10 @@ import { useTranslation } from "react-i18next";
import { TouchableOpacity, View, type ViewProps } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { getCurrentTab } from "@/utils/navigation";
import { HorizontalScroll } from "../common/HorizontalScroll";
import { Text } from "../common/Text";
+import { itemRouter } from "../common/TouchableItemRouter";
import Poster from "../posters/Poster";
interface Props extends ViewProps {
@@ -21,9 +23,9 @@ interface Props extends ViewProps {
export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
- const segments = useSegments();
+ const segments = useSegments() as string[];
const { t } = useTranslation();
- const from = (segments as string[])[2];
+ const from = getCurrentTab(segments);
const destinctPeople = useMemo(() => {
const people: Record = {};
@@ -55,12 +57,14 @@ export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
renderItem={(i) => (
{
- if (i.Id) {
- router.push({
- pathname: "/persons/[personId]",
- params: { personId: i.Id },
- });
- }
+ const url = itemRouter(
+ {
+ Id: i.Id,
+ Type: "Person",
+ },
+ from,
+ );
+ router.push(url as any);
}}
className='flex flex-col w-28'
>
diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx
index cfb62972..72743b7e 100644
--- a/components/series/SeasonPicker.tsx
+++ b/components/series/SeasonPicker.tsx
@@ -1,7 +1,14 @@
+const MissingDownloadIconComponent = () => (
+
+);
+const DownloadedIconComponent = () => (
+
+);
+
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 } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -70,7 +77,7 @@ export const SeasonPicker: React.FC = ({ item }) => {
if (!season?.Id) return null;
- return season.Id!;
+ return season.Id;
}, [seasons, seasonIndex]);
const { data: episodes, isPending } = useQuery({
@@ -100,6 +107,8 @@ export const SeasonPicker: React.FC = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
+ const _queryClient = useQueryClient();
+
// Used for height calculation
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
useEffect(() => {
@@ -133,12 +142,8 @@ export const SeasonPicker: React.FC = ({ item }) => {
title={t("item_card.download.download_season")}
className='ml-2'
items={episodes || []}
- MissingDownloadIconComponent={() => (
-
- )}
- DownloadedIconComponent={() => (
-
- )}
+ MissingDownloadIconComponent={MissingDownloadIconComponent}
+ DownloadedIconComponent={DownloadedIconComponent}
/>
diff --git a/components/settings/HomeIndex.tsx b/components/settings/HomeIndex.tsx
index 489cb0a1..527a246a 100644
--- a/components/settings/HomeIndex.tsx
+++ b/components/settings/HomeIndex.tsx
@@ -1,3 +1,23 @@
+function renderHeaderLeft(hasDownloads: boolean, onPress: () => void) {
+ return (
+
+ );
+}
+// ...imports...
+
+const DownloadsHeaderButton: React.FC<{
+ hasDownloads: boolean;
+ onPress: () => void;
+}> = ({ hasDownloads, onPress }) => (
+
+
+
+);
+
import { Feather, Ionicons } from "@expo/vector-icons";
import type { Api } from "@jellyfin/sdk";
import type {
@@ -99,20 +119,8 @@ export const HomeIndex = () => {
}
const hasDownloads = getDownloadedItems().length > 0;
navigation.setOptions({
- headerLeft: () => (
- {
- router.push("/(auth)/downloads");
- }}
- className='p-2'
- >
-
-
- ),
+ headerLeft: () =>
+ renderHeaderLeft(hasDownloads, () => router.push("/(auth)/downloads")),
});
}, [navigation, router]);
@@ -122,10 +130,10 @@ export const HomeIndex = () => {
);
}, []);
- const segments = useSegments();
+ const segments = useSegments() as string[];
useEffect(() => {
const unsubscribe = eventBus.on("scrollToTop", () => {
- if ((segments as string[])[2] === "(home)")
+ if (segments[2] === "(home)")
scrollViewRef.current?.scrollTo({ y: -152, animated: true });
});
@@ -313,10 +321,10 @@ export const HomeIndex = () => {
if (!api || !user?.Id || !settings?.home?.sections) return [];
const ss: Section[] = [];
for (const [index, section] of settings.home.sections.entries()) {
- const id = section.title || `section-${index}`;
+ const id = `section-${index}`;
ss.push({
title: t(`${id}`),
- queryKey: ["home", "custom", String(index), section.title ?? null],
+ queryKey: ["home", id],
queryFn: async () => {
if (section.items) {
const response = await getItemsApi(api).getItems({
@@ -364,8 +372,8 @@ export const HomeIndex = () => {
const sections = settings?.home?.sections ? customSections : defaultSections;
if (!isConnected || serverConnected !== true) {
- let title = "";
- let subtitle = "";
+ let title: string = "";
+ let subtitle: string = "";
if (!isConnected) {
// No network connection
@@ -460,7 +468,7 @@ export const HomeIndex = () => {
if (section.type === "ScrollingCollectionList") {
return (
{
if (section.type === "MediaListSection") {
return (
diff --git a/components/settings/QuickConnect.tsx b/components/settings/QuickConnect.tsx
index d0cbf85e..5871149f 100644
--- a/components/settings/QuickConnect.tsx
+++ b/components/settings/QuickConnect.tsx
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import { Alert, Platform, View, type ViewProps } from "react-native";
import { useHaptic } from "@/hooks/useHaptic";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { writeErrorLog } from "@/utils/log";
import { Button } from "../Button";
import { Text } from "../common/Text";
import { PinInput } from "../inputs/PinInput";
@@ -64,7 +65,8 @@ export const QuickConnect: React.FC = ({ ...props }) => {
t("home.settings.quick_connect.invalid_code"),
);
}
- } catch (_e) {
+ } catch (error) {
+ writeErrorLog("quickConnect.authenticationError", error);
errorHapticFeedback();
Alert.alert(
t("home.settings.quick_connect.error"),
@@ -119,6 +121,8 @@ export const QuickConnect: React.FC = ({ ...props }) => {
)}
;
isPlaying: boolean;
isSeeking: SharedValue;
cacheProgress: SharedValue;
@@ -58,7 +57,6 @@ interface Props {
isBuffering: boolean;
showControls: boolean;
- enableTrickplay?: boolean;
togglePlay: () => void;
setShowControls: (shown: boolean) => void;
offline?: boolean;
@@ -208,8 +206,8 @@ export const Controls: FC = ({
// Navigation hooks
const {
- handleSeekBackward,
- handleSeekForward,
+ handleSeekBackward: asyncHandleSeekBackward,
+ handleSeekForward: asyncHandleSeekForward,
handleSkipBackward,
handleSkipForward,
} = useVideoNavigation({
@@ -220,6 +218,21 @@ export const Controls: FC = ({
play,
});
+ // Create sync wrappers for remote control
+ const handleSeekBackward = useCallback(
+ (seconds: number) => {
+ asyncHandleSeekBackward(seconds);
+ },
+ [asyncHandleSeekBackward],
+ );
+
+ const handleSeekForward = useCallback(
+ (seconds: number) => {
+ asyncHandleSeekForward(seconds);
+ },
+ [asyncHandleSeekForward],
+ );
+
// Time management hook
const { currentTime, remainingTime } = useVideoTime({
progress,
@@ -377,7 +390,7 @@ export const Controls: FC = ({
item.UserData?.PlaybackPositionTicks?.toString() ?? "",
}).toString();
- console.log("queryParams", queryParams);
+ writeDebugLog("controls.navigate.queryParams", { queryParams });
router.replace(`player/direct-player?${queryParams}` as any);
},
diff --git a/components/video-player/controls/contexts/VideoContext.tsx b/components/video-player/controls/contexts/VideoContext.tsx
index a5fffd48..8ee1cb22 100644
--- a/components/video-player/controls/contexts/VideoContext.tsx
+++ b/components/video-player/controls/contexts/VideoContext.tsx
@@ -10,6 +10,7 @@ import {
useState,
} from "react";
import type { TrackInfo } from "@/modules/VlcPlayer.types";
+import { writeDebugLog } from "@/utils/log";
import type { Track } from "../types";
import { useControlContext } from "./ControlContext";
@@ -85,7 +86,7 @@ export const VideoProvider: React.FC = ({
chosenAudioIndex?: string;
chosenSubtitleIndex?: string;
}) => {
- console.log("chosenSubtitleIndex", chosenSubtitleIndex);
+ writeDebugLog("video.setPlayerParams", { chosenSubtitleIndex });
const queryParams = new URLSearchParams({
itemId: itemId ?? "",
audioIndex: chosenAudioIndex,
@@ -114,7 +115,7 @@ export const VideoProvider: React.FC = ({
mediaSource?.TranscodingUrl &&
!onTextBasedSubtitle;
- console.log("Set player params", index, serverIndex);
+ writeDebugLog("video.setTrackParams", { type, index, serverIndex });
if (shouldChangePlayerParams) {
setPlayerParams({
chosenSubtitleIndex: serverIndex.toString(),
@@ -127,6 +128,57 @@ export const VideoProvider: React.FC = ({
});
};
+ // Extract subtitle track creation to reduce nesting
+ const createSubtitleTrack = (
+ sub: any,
+ subtitleData: TrackInfo[] | null,
+ embedSubIndex: { current: number },
+ ): Track => {
+ const shouldIncrement =
+ sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
+ sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
+ sub.DeliveryMethod === SubtitleDeliveryMethod.External;
+
+ const vlcIndex = subtitleData?.at(embedSubIndex.current)?.index ?? -1;
+ if (shouldIncrement) embedSubIndex.current++;
+
+ const handleSetTrack = () => {
+ if (shouldIncrement) {
+ setTrackParams("subtitle", vlcIndex, sub.Index ?? -1);
+ } else {
+ setPlayerParams({ chosenSubtitleIndex: sub.Index?.toString() });
+ }
+ };
+
+ return {
+ name: sub.DisplayTitle || "Undefined Subtitle",
+ index: sub.Index ?? -1,
+ setTrack: handleSetTrack,
+ };
+ };
+
+ // Extract audio track creation to reduce nesting
+ const createAudioTrack = (
+ audio: any,
+ idx: number,
+ audioData: TrackInfo[] | null,
+ ): Track => {
+ const handleSetTrack = () => {
+ if (!mediaSource?.TranscodingUrl) {
+ const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
+ setTrackParams("audio", vlcIndex, audio.Index ?? -1);
+ } else {
+ setPlayerParams({ chosenAudioIndex: audio.Index?.toString() });
+ }
+ };
+
+ return {
+ name: audio.DisplayTitle ?? "Undefined Audio",
+ index: audio.Index ?? -1,
+ setTrack: handleSetTrack,
+ };
+ };
+
useEffect(() => {
const fetchTracks = async () => {
if (getSubtitleTracks) {
@@ -140,73 +192,47 @@ export const VideoProvider: React.FC = ({
subtitleData = [subtitleData[0], ...subtitleData.slice(1).reverse()];
}
- let embedSubIndex = 1;
- const processedSubs: Track[] = allSubs?.map((sub) => {
- /** A boolean value determining if we should increment the embedSubIndex, currently only Embed and Hls subtitles are automatically added into VLC Player */
- const shouldIncrement =
- sub.DeliveryMethod === SubtitleDeliveryMethod.Embed ||
- sub.DeliveryMethod === SubtitleDeliveryMethod.Hls ||
- sub.DeliveryMethod === SubtitleDeliveryMethod.External;
- /** The index of subtitle inside VLC Player Itself */
- const vlcIndex = subtitleData?.at(embedSubIndex)?.index ?? -1;
- if (shouldIncrement) embedSubIndex++;
- return {
- name: sub.DisplayTitle || "Undefined Subtitle",
- index: sub.Index ?? -1,
- setTrack: () =>
- shouldIncrement
- ? setTrackParams("subtitle", vlcIndex, sub.Index ?? -1)
- : setPlayerParams({
- chosenSubtitleIndex: sub.Index?.toString(),
- }),
- };
- });
+ const embedSubIndex = { current: 1 };
+ const processedSubs: Track[] = allSubs?.map((sub) =>
+ createSubtitleTrack(sub, subtitleData, embedSubIndex),
+ );
// Step 3: Restore the original order
- const subtitles: Track[] = processedSubs.sort(
+ const subtitles: Track[] = processedSubs.toSorted(
(a, b) => a.index - b.index,
);
// Add a "Disable Subtitles" option
- subtitles.unshift({
+ const disableSubtitleTrack = {
name: "Disable",
index: -1,
- setTrack: () =>
- !mediaSource?.TranscodingUrl || onTextBasedSubtitle
- ? setTrackParams("subtitle", -1, -1)
- : setPlayerParams({ chosenSubtitleIndex: "-1" }),
- });
+ setTrack: () => {
+ if (!mediaSource?.TranscodingUrl || onTextBasedSubtitle) {
+ setTrackParams("subtitle", -1, -1);
+ } else {
+ setPlayerParams({ chosenSubtitleIndex: "-1" });
+ }
+ },
+ };
+ subtitles.unshift(disableSubtitleTrack);
setSubtitleTracks(subtitles);
}
if (getAudioTracks) {
const audioData = await getAudioTracks();
const allAudio =
mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") || [];
- const audioTracks: Track[] = allAudio?.map((audio, idx) => {
- if (!mediaSource?.TranscodingUrl) {
- const vlcIndex = audioData?.at(idx + 1)?.index ?? -1;
- return {
- name: audio.DisplayTitle ?? "Undefined Audio",
- index: audio.Index ?? -1,
- setTrack: () =>
- setTrackParams("audio", vlcIndex, audio.Index ?? -1),
- };
- }
- return {
- name: audio.DisplayTitle ?? "Undefined Audio",
- index: audio.Index ?? -1,
- setTrack: () =>
- setPlayerParams({ chosenAudioIndex: audio.Index?.toString() }),
- };
- });
+ const audioTracks: Track[] = allAudio?.map((audio, idx) =>
+ createAudioTrack(audio, idx, audioData),
+ );
// Add a "Disable Audio" option if its not transcoding.
if (!mediaSource?.TranscodingUrl) {
- audioTracks.unshift({
+ const disableAudioTrack = {
name: "Disable",
index: -1,
setTrack: () => setTrackParams("audio", -1, -1),
- });
+ };
+ audioTracks.unshift(disableAudioTrack);
}
setAudioTracks(audioTracks);
}
@@ -214,16 +240,25 @@ export const VideoProvider: React.FC = ({
fetchTracks();
}, [isVideoLoaded, getAudioTracks, getSubtitleTracks]);
+ const contextValue = useMemo(
+ () => ({
+ audioTracks,
+ subtitleTracks,
+ setSubtitleTrack,
+ setSubtitleURL,
+ setAudioTrack,
+ }),
+ [
+ audioTracks,
+ subtitleTracks,
+ setSubtitleTrack,
+ setSubtitleURL,
+ setAudioTrack,
+ ],
+ );
+
return (
-
+
{children}
);
diff --git a/components/video-player/controls/dropdown/DropdownView.tsx b/components/video-player/controls/dropdown/DropdownView.tsx
index c6c8f82f..939e5c27 100644
--- a/components/video-player/controls/dropdown/DropdownView.tsx
+++ b/components/video-player/controls/dropdown/DropdownView.tsx
@@ -140,8 +140,8 @@ const DropdownView = () => {
}}
className='flex flex-col rounded-xl overflow-hidden'
>
- {BITRATES?.map((bitrate, idx: number) => (
-
+ {BITRATES?.map((bitrate) => (
+
{
changeBitrate(bitrate.value?.toString() ?? "");
@@ -164,7 +164,8 @@ const DropdownView = () => {
/>
)}
- {idx < BITRATES.length - 1 && (
+ {BITRATES.findIndex((b) => b.value === bitrate.value) <
+ BITRATES.length - 1 && (
{
}}
className='flex flex-col rounded-xl overflow-hidden'
>
- {subtitleTracks?.map((sub, idx: number) => (
-
+ {subtitleTracks?.map((subtitle) => (
+
{
- sub.setTrack();
+ subtitle.setTrack();
setTimeout(() => handleClose(), 250);
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
- {sub.name}
- {subtitleIndex === sub.index.toString() ? (
+ {subtitle.name}
+ {subtitleIndex === subtitle.index.toString() ? (
{
/>
)}
- {idx < (subtitleTracks?.length ?? 0) - 1 && (
+ {(subtitleTracks?.findIndex(
+ (s) => s.index === subtitle.index,
+ ) ?? 0) <
+ (subtitleTracks?.length ?? 0) - 1 && (
{
}}
className='flex flex-col rounded-xl overflow-hidden'
>
- {audioTracks?.map((track, idx: number) => (
-
+ {audioTracks?.map((audio) => (
+
{
- track.setTrack();
+ audio.setTrack();
setTimeout(() => handleClose(), 250);
}}
className='bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between'
>
- {track.name}
- {audioIndex === track.index.toString() ? (
+ {audio.name}
+ {audioIndex === audio.index.toString() ? (
{
/>
)}
- {idx < (audioTracks?.length ?? 0) - 1 && (
+ {(audioTracks?.findIndex(
+ (a) => a.index === audio.index,
+ ) ?? 0) <
+ (audioTracks?.length ?? 0) - 1 && (
{
);
const isJellyseerrMovieOrTvResult = (
- items: any | null | undefined,
+ items: MediaItem | null | undefined,
): items is MovieResult | TvResult => {
- return (
+ return Boolean(
items &&
- Object.hasOwn(items, "mediaType") &&
- (items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
+ Object.hasOwn(items, "mediaType") &&
+ ((items as MovieResult | TvResult).mediaType === MediaType.MOVIE ||
+ (items as MovieResult | TvResult).mediaType === MediaType.TV),
);
};
- const getTitle = (
- item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
- ) => {
- return isJellyseerrMovieOrTvResult(item)
- ? item.mediaType === MediaType.MOVIE
- ? item?.title
- : item?.name
- : item?.mediaInfo?.mediaType === MediaType.MOVIE
- ? (item as MovieDetails)?.title
- : (item as TvDetails)?.name;
+ type MediaItem =
+ | TvResult
+ | TvDetails
+ | MovieResult
+ | MovieDetails
+ | PersonCreditCast
+ | CollectionResult
+ | PersonResult;
+
+ const getTitle = (item?: MediaItem) => {
+ if (isJellyseerrMovieOrTvResult(item)) {
+ return item.mediaType === MediaType.MOVIE ? item?.title : item?.name;
+ }
+
+ // Handle CollectionResult
+ if (item && "title" in item) {
+ return item.title;
+ }
+
+ if (item && "mediaInfo" in item) {
+ if (item.mediaInfo?.mediaType === MediaType.MOVIE) {
+ return (item as unknown as MovieDetails)?.title;
+ }
+ return (item as unknown as TvDetails)?.name;
+ }
+
+ return undefined;
};
- const getYear = (
- item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
- ) => {
- return new Date(
- (isJellyseerrMovieOrTvResult(item)
- ? item.mediaType === MediaType.MOVIE
+ const getYear = (item?: MediaItem) => {
+ let dateString = "";
+
+ if (isJellyseerrMovieOrTvResult(item)) {
+ dateString =
+ (item.mediaType === MediaType.MOVIE
? item?.releaseDate
- : item?.firstAirDate
- : item?.mediaInfo?.mediaType === MediaType.MOVIE
- ? (item as MovieDetails)?.releaseDate
- : (item as TvDetails)?.firstAirDate) || "",
- )?.getFullYear?.();
+ : item?.firstAirDate) || "";
+ } else if (item && "mediaInfo" in item) {
+ if (item.mediaInfo?.mediaType === MediaType.MOVIE) {
+ dateString = (item as unknown as MovieDetails)?.releaseDate || "";
+ } else {
+ dateString = (item as unknown as TvDetails)?.firstAirDate || "";
+ }
+ }
+
+ return new Date(dateString)?.getFullYear?.();
};
- const getMediaType = (
- item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
- ): MediaType => {
- return isJellyseerrMovieOrTvResult(item)
- ? (item.mediaType as MediaType)
- : item?.mediaInfo?.mediaType;
+ const getMediaType = (item?: MediaItem): MediaType | undefined => {
+ if (isJellyseerrMovieOrTvResult(item)) {
+ return item.mediaType as MediaType;
+ }
+
+ if (item && "mediaInfo" in item) {
+ return item.mediaInfo?.mediaType;
+ }
+
+ return undefined;
};
+ // Adjusted to match current UserSettings field name (discoverRegion)
const jellyseerrRegion = useMemo(
() => jellyseerrUser?.settings?.discoverRegion || "US",
[jellyseerrUser],
diff --git a/providers/DownloadProvider.tsx b/providers/DownloadProvider.tsx
index c3180c0a..24675f7a 100644
--- a/providers/DownloadProvider.tsx
+++ b/providers/DownloadProvider.tsx
@@ -32,6 +32,7 @@ import { generateTrickplayUrl, getTrickplayInfo } from "@/utils/trickplay";
import { Bitrate } from "../components/BitrateSelector";
import {
DownloadedItem,
+ DownloadedSeries,
DownloadsDatabase,
JobStatus,
TrickPlayData,
@@ -161,30 +162,38 @@ function useDownloadProvider() {
return;
}
// check if processes are missing
+ const hasMetadataAndNotInProcesses = (task: any) => {
+ return task.metadata && !processes.some((p) => p.id === task.id);
+ };
+
+ const findTaskById = (taskId: string) => {
+ return tasks.find((taskItem: any) => taskItem.id === taskId);
+ };
+
setProcesses((processes) => {
const missingProcesses = tasks
- .filter((t: any) => t.metadata && !processes.some((p) => p.id === t.id))
- .map((t: any) => {
- return t.metadata as JobStatus;
+ .filter(hasMetadataAndNotInProcesses)
+ .map((task: any) => {
+ return task.metadata as JobStatus;
});
const currentProcesses = [...processes, ...missingProcesses];
- const updatedProcesses = currentProcesses.map((p) => {
+ const updatedProcesses = currentProcesses.map((process) => {
// fallback. Doesn't really work for transcodes as they may be a lot smaller.
// We make an wild guess by comparing bitrates
- const task = tasks.find((s: any) => s.id === p.id);
- if (task && p.status === "downloading") {
- const estimatedSize = calculateEstimatedSize(p);
- let progress = p.progress;
+ const task = findTaskById(process.id);
+ if (task && process.status === "downloading") {
+ const estimatedSize = calculateEstimatedSize(process);
+ let progress = process.progress;
if (estimatedSize > 0) {
progress = (100 / estimatedSize) * task.bytesDownloaded;
}
if (progress >= 100) {
progress = 99;
}
- const speed = calculateSpeed(p, task.bytesDownloaded);
+ const speed = calculateSpeed(process, task.bytesDownloaded);
return {
- ...p,
+ ...process,
progress,
speed,
bytesDownloaded: task.bytesDownloaded,
@@ -192,7 +201,7 @@ function useDownloadProvider() {
estimatedTotalSizeBytes: estimatedSize,
};
}
- return p;
+ return process;
});
return updatedProcesses;
@@ -480,7 +489,7 @@ function useDownloadProvider() {
episodeNumber
] = downloadedItem;
}
- await saveDownloadsDatabase(db);
+ saveDownloadsDatabase(db);
// Send native notification for successful download
const successNotification = getNotificationContent(
@@ -574,7 +583,8 @@ function useDownloadProvider() {
await FileSystem.makeDirectoryAsync(APP_CACHE_DOWNLOAD_DIRECTORY, {
intermediates: true,
});
- } catch (_error) {
+ } catch (error) {
+ console.error("Failed to clean cache directory:", error);
toast.error(t("Failed to clean cache directory."));
}
};
@@ -600,12 +610,12 @@ function useDownloadProvider() {
});
await saveImage(item.Id, itemImage?.uri);
const job: JobStatus = {
- id: item.Id!,
+ id: item.Id,
deviceId: deviceId,
maxBitrate,
inputUrl: url,
item: item,
- itemId: item.Id!,
+ itemId: item.Id,
mediaSource,
progress: 0,
status: "queued",
@@ -633,54 +643,60 @@ function useDownloadProvider() {
[authHeader, startDownload],
);
- const deleteFile = async (id: string, type: "Movie" | "Episode") => {
- const db = getDownloadsDatabase();
- let downloadedItem: DownloadedItem | undefined;
+ const findAndDeleteMovie = (
+ db: DownloadsDatabase,
+ id: string,
+ ): DownloadedItem | undefined => {
+ const downloadedItem = db.movies[id];
+ if (downloadedItem) {
+ delete db.movies[id];
+ }
+ return downloadedItem;
+ };
- if (type === "Movie") {
- downloadedItem = db.movies[id];
- if (downloadedItem) {
- delete db.movies[id];
- }
- } else if (type === "Episode") {
- const cleanUpEmptyParents = (
- series: any,
- seasonNumber: string,
- seriesId: string,
- ) => {
- if (!Object.keys(series.seasons[seasonNumber].episodes).length) {
- delete series.seasons[seasonNumber];
- }
- if (!Object.keys(series.seasons).length) {
- delete db.series[seriesId];
- }
- };
+ const cleanUpEmptyParents = (
+ series: DownloadedSeries,
+ seasonNumber: string,
+ seriesId: string,
+ db: DownloadsDatabase,
+ ) => {
+ if (!Object.keys(series.seasons[Number(seasonNumber)].episodes).length) {
+ delete series.seasons[Number(seasonNumber)];
+ }
+ if (!Object.keys(series.seasons).length) {
+ delete db.series[seriesId];
+ }
+ };
- for (const [seriesId, series] of Object.entries(db.series)) {
- for (const [seasonNumber, season] of Object.entries(series.seasons)) {
- for (const [episodeNumber, episode] of Object.entries(
- season.episodes,
- )) {
- if (episode.item.Id === id) {
- downloadedItem = episode;
- delete season.episodes[Number(episodeNumber)];
- cleanUpEmptyParents(series, seasonNumber, seriesId);
- break;
- }
+ const findAndDeleteEpisode = (
+ db: DownloadsDatabase,
+ id: string,
+ ): DownloadedItem | undefined => {
+ for (const [seriesId, series] of Object.entries(db.series)) {
+ for (const [seasonNumber, season] of Object.entries(series.seasons)) {
+ for (const [episodeNumber, episode] of Object.entries(
+ season.episodes,
+ )) {
+ if (episode.item.Id === id) {
+ const downloadedItem = episode;
+ delete season.episodes[Number(episodeNumber)];
+ cleanUpEmptyParents(series, seasonNumber, seriesId, db);
+ return downloadedItem;
}
- if (downloadedItem) break;
}
- if (downloadedItem) break;
}
}
+ return undefined;
+ };
- if (downloadedItem?.videoFilePath) {
+ const deleteMediaFiles = async (downloadedItem: DownloadedItem) => {
+ if (downloadedItem.videoFilePath) {
await FileSystem.deleteAsync(downloadedItem.videoFilePath, {
idempotent: true,
});
}
- if (downloadedItem?.mediaSource?.MediaStreams) {
+ if (downloadedItem.mediaSource?.MediaStreams) {
for (const stream of downloadedItem.mediaSource.MediaStreams) {
if (
stream.Type === "Subtitle" &&
@@ -693,13 +709,28 @@ function useDownloadProvider() {
}
}
- if (downloadedItem?.trickPlayData?.path) {
+ if (downloadedItem.trickPlayData?.path) {
await FileSystem.deleteAsync(downloadedItem.trickPlayData.path, {
idempotent: true,
});
}
+ };
- await saveDownloadsDatabase(db);
+ const deleteFile = async (id: string, type: "Movie" | "Episode") => {
+ const db = getDownloadsDatabase();
+ let downloadedItem: DownloadedItem | undefined;
+
+ if (type === "Movie") {
+ downloadedItem = findAndDeleteMovie(db, id);
+ } else if (type === "Episode") {
+ downloadedItem = findAndDeleteEpisode(db, id);
+ }
+
+ if (downloadedItem) {
+ await deleteMediaFiles(downloadedItem);
+ }
+
+ saveDownloadsDatabase(db);
successHapticFeedback();
};
@@ -739,6 +770,25 @@ function useDownloadProvider() {
return downloadedItem.videoFileSize + trickplaySize;
};
+ /** Helper function to update episode in series database */
+ const updateEpisodeInSeries = (
+ db: DownloadsDatabase,
+ itemId: string,
+ updatedItem: DownloadedItem,
+ ) => {
+ for (const series of Object.values(db.series)) {
+ for (const season of Object.values(series.seasons)) {
+ for (const episode of Object.values(season.episodes)) {
+ if (episode.item.Id === itemId) {
+ season.episodes[episode.item.IndexNumber as number] = updatedItem;
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ };
+
/** Updates a downloaded item. */
const updateDownloadedItem = (
itemId: string,
@@ -748,15 +798,7 @@ function useDownloadProvider() {
if (db.movies[itemId]) {
db.movies[itemId] = updatedItem;
} else {
- for (const series of Object.values(db.series)) {
- for (const season of Object.values(series.seasons)) {
- for (const episode of Object.values(season.episodes)) {
- if (episode.item.Id === itemId) {
- season.episodes[episode.item.IndexNumber as number] = updatedItem;
- }
- }
- }
- }
+ updateEpisodeInSeries(db, itemId, updatedItem);
}
saveDownloadsDatabase(db);
};
@@ -898,7 +940,9 @@ export function useDownload() {
return context;
}
-export function DownloadProvider({ children }: { children: React.ReactNode }) {
+export function DownloadProvider({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
const downloadUtils = useDownloadProvider();
return (
diff --git a/translations/en.json b/translations/en.json
index 834fd183..f6efdc68 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -284,28 +284,7 @@
"collections": "Collections",
"actors": "Actors",
"request_movies": "Request Movies",
- "request_series": "Request Series",
- "recently_added": "Recently Added",
- "recent_requests": "Recent Requests",
- "plex_watchlist": "Plex Watchlist",
- "trending": "Trending",
- "popular_movies": "Popular Movies",
- "movie_genres": "Movie Genres",
- "upcoming_movies": "Upcoming Movies",
- "studios": "Studios",
- "popular_tv": "Popular TV",
- "tv_genres": "TV Genres",
- "upcoming_tv": "Upcoming TV",
- "networks": "Networks",
- "tmdb_movie_keyword": "TMDB Movie Keyword",
- "tmdb_movie_genre": "TMDB Movie Genre",
- "tmdb_tv_keyword": "TMDB TV Keyword",
- "tmdb_tv_genre": "TMDB TV Genre",
- "tmdb_search": "TMDB Search",
- "tmdb_studio": "TMDB Studio",
- "tmdb_network": "TMDB Network",
- "tmdb_movie_streaming_services": "TMDB Movie Streaming Services",
- "tmdb_tv_streaming_services": "TMDB TV Streaming Services"
+ "request_series": "Request Series"
},
"library": {
"no_results": "No Results",
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index c0780462..d24114c5 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -88,7 +88,6 @@ export type Home = {
};
export type HomeSection = {
- title?: string;
orientation?: "horizontal" | "vertical";
items?: HomeSectionItemResolver;
nextUp?: HomeSectionNextUpResolver;
diff --git a/utils/jellyfin/media/getDownloadUrl.ts b/utils/jellyfin/media/getDownloadUrl.ts
index 33734d4b..e631175e 100644
--- a/utils/jellyfin/media/getDownloadUrl.ts
+++ b/utils/jellyfin/media/getDownloadUrl.ts
@@ -4,6 +4,7 @@ import type {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { Bitrate } from "@/components/BitrateSelector";
+import { writeDebugLog } from "@/utils/log";
import { generateDeviceProfile } from "@/utils/profiles/native";
import { getDownloadStreamUrl, getStreamUrl } from "./getStreamUrl";
@@ -43,7 +44,7 @@ export const getDownloadUrl = async ({
});
if (maxBitrate.key === "Max" && !streamDetails?.mediaSource?.TranscodingUrl) {
- console.log("Downloading item directly");
+ writeDebugLog("download.directDownload", { itemId: item.Id });
return {
url: `${api.basePath}/Items/${item.Id}/Download?api_key=${api.accessToken}`,
mediaSource: streamDetails?.mediaSource ?? null,
diff --git a/utils/jellyfin/media/getStreamUrl.ts b/utils/jellyfin/media/getStreamUrl.ts
index 75300831..088a2e3c 100644
--- a/utils/jellyfin/media/getStreamUrl.ts
+++ b/utils/jellyfin/media/getStreamUrl.ts
@@ -5,6 +5,7 @@ import type {
} from "@jellyfin/sdk/lib/generated-client/models";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models/base-item-kind";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
+import { writeDebugLog } from "@/utils/log";
import download from "@/utils/profiles/download";
interface StreamResult {
@@ -45,7 +46,7 @@ const getPlaybackUrl = (
);
}
- console.log("Video is being transcoded:", transcodeUrl);
+ writeDebugLog("media.stream.transcoded", { transcodeUrl });
return `${api.basePath}${transcodeUrl}`;
}
@@ -70,7 +71,7 @@ const getPlaybackUrl = (
const directPlayUrl = `${api.basePath}/Videos/${itemId}/stream?${streamParams.toString()}`;
- console.log("Video is being direct played:", directPlayUrl);
+ writeDebugLog("media.stream.directPlay", { directPlayUrl });
return directPlayUrl;
};
@@ -164,7 +165,11 @@ export const getStreamUrl = async ({
mediaSource: MediaSourceInfo | undefined;
} | null> => {
if (!api || !userId || !item?.Id) {
- console.warn("Missing required parameters for getStreamUrl");
+ writeDebugLog("media.stream.missingParams", {
+ hasApi: !!api,
+ hasUserId: !!userId,
+ hasItemId: !!item?.Id,
+ });
return null;
}
@@ -173,7 +178,7 @@ export const getStreamUrl = async ({
// Please do not remove this we need this for live TV to be working correctly.
if (item.Type === BaseItemKind.Program) {
- console.log("Item is of type program...");
+ writeDebugLog("media.stream.programDetected", { itemId: item.Id });
const res = await getMediaInfoApi(api).getPlaybackInfo(
{
userId,
@@ -233,7 +238,10 @@ export const getStreamUrl = async ({
);
if (res.status !== 200) {
- console.error("Error getting playback info:", res.status, res.statusText);
+ writeDebugLog("media.stream.playbackInfoError", {
+ status: res.status,
+ statusText: res.statusText,
+ });
}
sessionId = res.data.PlaySessionId || null;
@@ -280,7 +288,11 @@ export const getDownloadStreamUrl = async ({
mediaSource: MediaSourceInfo | undefined;
} | null> => {
if (!api || !userId || !item?.Id) {
- console.warn("Missing required parameters for getStreamUrl");
+ writeDebugLog("media.downloadStream.missingParams", {
+ hasApi: !!api,
+ hasUserId: !!userId,
+ hasItemId: !!item?.Id,
+ });
return null;
}
@@ -305,7 +317,10 @@ export const getDownloadStreamUrl = async ({
);
if (res.status !== 200) {
- console.error("Error getting playback info:", res.status, res.statusText);
+ writeDebugLog("media.downloadStream.playbackInfoError", {
+ status: res.status,
+ statusText: res.statusText,
+ });
}
const sessionId = res.data.PlaySessionId || null;
diff --git a/utils/navigation.ts b/utils/navigation.ts
new file mode 100644
index 00000000..56d8ccc6
--- /dev/null
+++ b/utils/navigation.ts
@@ -0,0 +1,19 @@
+// Centralized helpers for navigation-related logic to reduce duplication and Sonar code smells.
+
+// Navigation tab constants to eliminate string literal duplication
+export const TAB_ROUTES = {
+ HOME: "(home)",
+ SEARCH: "(search)",
+ LIBRARIES: "(libraries)",
+ FAVORITES: "(favorites)",
+} as const;
+
+/**
+ * Derive current tab/root segment from expo-router segments array.
+ * Falls back gracefully to the last available segment or HOME.
+ */
+export function getCurrentTab(segments: readonly string[]): string {
+ if (!segments || segments.length === 0) return TAB_ROUTES.HOME;
+ if (segments.length > 2) return segments[2] || TAB_ROUTES.HOME;
+ return segments[segments.length - 1] || TAB_ROUTES.HOME;
+}