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 ( + + ); + } + + if (details?.mediaInfo?.jellyfinMediaId) { + return ( + + {!Platform.isTV && ( + + )} + + + ); + } + + return null; + }; return ( { g.name) || []} /> - {isLoading || isFetching ? ( - - ) : ( - 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; +}