diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 2bc74bc1..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone15Pro] - - OS: [e.g. iOS18] - - Version [e.g. 0.3.1] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..82018c50 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,59 @@ +name: Bug report +description: Create a report to help us improve +title: "[Bug]: " +labels: + - ["❌ bug"] +projects: + - ["fredrikburmester/5"] +assignees: + - fredrikburmester + +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Reproduction steps + description: "How do you trigger this bug? Please walk us through it step by step." + placeholder: | + 1. + 2. + 3. + ... + validations: + required: true + + - type: textarea + id: device + attributes: + label: Which device and operating system are you using? + description: e.g. iPhone 15, iOS 18.1.1 + validations: + required: true + + - type: dropdown + id: version + attributes: + label: Version + description: What version of Streamyfin are you running? + options: + - 0.23.0 + - 0.22.0 + - 0.21.0 + - older + validations: + required: true + + - type: textarea + id: screenshots + attributes: + label: If applicable, please add screenshots to help explain your problem. + You can drag and drop images here or paste them directly into the comment box. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 80ae5ceb..544b2743 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature request about: Suggest an idea for this project title: '' -labels: '' +labels: '✨ enhancement' assignees: '' --- diff --git a/.gitignore b/.gitignore index 79a91cb4..33ed8e6d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ npm-debug.* *.mobileprovision *.orig.* web-build/ +modules/vlc-player/android/build # macOS .DS_Store @@ -21,11 +22,17 @@ build-* *.mp4 build-* Streamyfin.app +package-lock.json /ios /android +modules/player/android + pc-api-7079014811501811218-719-3b9f15aeccf8.json credentials.json *.apk *.ipa +.continuerc.json + +.vscode/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..32e4ce9f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "utils/jellyseerr"] + path = utils/jellyseerr + url = https://github.com/herrrta/jellyseerr + branch = models diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 00000000..b81700b5 --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,329 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..639900d1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..ba6d5c31 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/streamyfin.iml b/.idea/streamyfin.iml new file mode 100644 index 00000000..d6ebd480 --- /dev/null +++ b/.idea/streamyfin.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 2fe7c24f..22480b68 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,8 @@ "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true + }, + "[swift]": { + "editor.defaultFormatter": "sswg.swift-lang" } } diff --git a/README.md b/README.md index 3810f724..360f4949 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # 📺 Streamyfin +Buy Me A Coffee + Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox. -
- - - - - +
+ + + +
## 🌟 Features -- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more. +- 🚀 **Skp intro / credits support** +- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking. - 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone. - 🔊 **Background audio**: Stream music in the background, even when locking the phone. - 📥 **Download media** (Experimental): Save your media locally and watch it offline. @@ -24,7 +26,34 @@ Streamyfin includes some exciting experimental features like media downloading a ### Downloading -Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode. +Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode. + +### Chromecast + +Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features. + +## Plugins + +In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality. + +### Collection rows + +Jellyfin collections can be shown as rows or carousel on the home screen. +The following tags can be added to a collection to provide this functionality. + +Available tags: + +- sf_promoted: will make the collection a row at home +- sf_carousel: will make the collection a carousel on home. + +A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc. +See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info. + +### Jellysearch + +[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀 + +> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. ## Roadmap for V1 @@ -32,16 +61,13 @@ Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to ## Get it now -
- - Get Streamyfin on App Store - - - - Get the beta on Google Play - +
+ Get Streamyfin on App Store + Get the beta on Google Play
+Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android. + ### Beta testing Get the latest updates by using the TestFlight version of the app. @@ -50,8 +76,6 @@ Get the latest updates by using the TestFlight version of the app. Get the beta on TestFlight -Or download the APKs here on GitHub for Android. - ## 🚀 Getting Started ### Prerequisites @@ -65,6 +89,12 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea ### Development info +1. Use node `20` +2. Install dependencies `bun i` +3. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. + +## Extended chromecast controls + Add this to AppDelegate.mm: ``` @@ -106,17 +136,13 @@ Key points of the MPL-2.0: ## 🌐 Connect with Us -Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/zyGKHJZvv4) +Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE) If you have questions or need support, feel free to reach out: - GitHub Issues: Report bugs or request features here. - Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com) -## Support - -Buy Me A Coffee - ## 📝 Credits Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries. @@ -128,3 +154,7 @@ I'd like to thank the following people and projects for their contributions to S - [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API. - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK. - The Jellyfin devs for always being helpful in the Discord. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=fredrikburmester/streamyfin&type=Date)](https://star-history.com/#fredrikburmester/streamyfin&Date) diff --git a/app.json b/app.json index cf0e5b0f..81ba2a80 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Streamyfin", "slug": "streamyfin", - "version": "0.6.1", + "version": "0.23.0", "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "streamyfin", @@ -10,7 +10,7 @@ "splash": { "image": "./assets/images/splash.png", "resizeMode": "contain", - "backgroundColor": "#29164B" + "backgroundColor": "#2E2E2E" }, "jsEngine": "hermes", "assetBundlePatterns": ["**/*"], @@ -19,32 +19,34 @@ "infoPlist": { "NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.", "NSMicrophoneUsageDescription": "The app needs access to your microphone.", - "UIBackgroundModes": ["audio"], + "UIBackgroundModes": ["audio", "fetch"], "NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.", "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": true - } + }, + "UISupportsTrueScreenSizeOnMac": true, + "UIFileSharingEnabled": true, + "LSSupportsOpeningDocumentsInPlace": true + }, + "config": { + "usesNonExemptEncryption": false }, "supportsTablet": true, "bundleIdentifier": "com.fredrikburmester.streamyfin" }, "android": { "jsEngine": "hermes", - "versionCode": 18, + "versionCode": 49, "adaptiveIcon": { - "foregroundImage": "./assets/images/icon.png" + "foregroundImage": "./assets/images/adaptive_icon.png" }, "package": "com.fredrikburmester.streamyfin", "permissions": [ "android.permission.FOREGROUND_SERVICE", - "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" + "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK", + "android.permission.WRITE_SETTINGS" ] }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/images/favicon.png" - }, "plugins": [ "expo-router", "expo-font", @@ -72,9 +74,15 @@ "expo-build-properties", { "ios": { - "deploymentTarget": "14.0" + "deploymentTarget": "15.6", + "useFrameworks": "static" }, "android": { + "android": { + "compileSdkVersion": 34, + "targetSdkVersion": 34, + "buildToolsVersion": "34.0.0" + }, "minSdkVersion": 24, "usesCleartextTraffic": true, "packagingOptions": { @@ -97,7 +105,14 @@ "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." } ], - "expo-localization" + "expo-localization", + "expo-asset", + [ + "react-native-edge-to-edge", + { "android": { "parentTheme": "Material3" } } + ], + ["react-native-bottom-tabs"], + ["./plugins/withChangeNativeAndroidTextToWhite.js"] ], "experiments": { "typedRoutes": true diff --git a/app/(auth)/(tabs)/search/_layout.tsx b/app/(auth)/(tabs)/(custom-links)/_layout.tsx similarity index 63% rename from app/(auth)/(tabs)/search/_layout.tsx rename to app/(auth)/(tabs)/(custom-links)/_layout.tsx index 8ba7b396..ed0529d4 100644 --- a/app/(auth)/(tabs)/search/_layout.tsx +++ b/app/(auth)/(tabs)/(custom-links)/_layout.tsx @@ -1,7 +1,7 @@ -import { Stack } from "expo-router"; +import {Stack} from "expo-router"; import { Platform } from "react-native"; -export default function SearchLayout() { +export default function CustomMenuLayout() { return ( diff --git a/app/(auth)/(tabs)/(custom-links)/index.tsx b/app/(auth)/(tabs)/(custom-links)/index.tsx new file mode 100644 index 00000000..76b10fb8 --- /dev/null +++ b/app/(auth)/(tabs)/(custom-links)/index.tsx @@ -0,0 +1,73 @@ +import {FlatList, TouchableOpacity, View} from "react-native"; +import {useSafeAreaInsets} from "react-native-safe-area-context"; +import React, {useCallback, useEffect, useState} from "react"; +import {useAtom} from "jotai/index"; +import {apiAtom} from "@/providers/JellyfinProvider"; +import {ListItem} from "@/components/ListItem"; +import * as WebBrowser from 'expo-web-browser'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import {Text} from "@/components/common/Text"; + +export interface MenuLink { + name: string, + url: string, + icon: string +} + +export default function menuLinks() { + const [api] = useAtom(apiAtom); + const insets = useSafeAreaInsets() + const [menuLinks, setMenuLinks] = useState([]) + + const getMenuLinks = useCallback(async () => { + try { + const response = await api?.axiosInstance.get(api?.basePath + "/web/config.json") + const config = response?.data; + + if (!config && !config.hasOwnProperty("menuLinks")) { + console.error("Menu links not found"); + return; + } + + setMenuLinks(config?.menuLinks as MenuLink[]) + } catch (error) { + console.error("Failed to retrieve config:", error); + } + }, + [api] + ) + + useEffect(() => { getMenuLinks() }, []); + return ( + ( + WebBrowser.openBrowserAsync(item.url) }> + } + /> + + ) + } + ItemSeparatorComponent={() => ( + + )} + ListEmptyComponent={ + + No links + + } + /> + ); +} \ No newline at end of file diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx new file mode 100644 index 00000000..04114e43 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -0,0 +1,69 @@ +import { Chromecast } from "@/components/Chromecast"; +import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; +import { Feather } from "@expo/vector-icons"; +import { Stack, useRouter } from "expo-router"; +import { Platform, TouchableOpacity, View } from "react-native"; +import { useTranslation } from "react-i18next"; + +export default function IndexLayout() { + const router = useRouter(); + const { t } = useTranslation(); + return ( + + ( + + + { + router.push("/(auth)/settings"); + }} + > + + + + ), + }} + /> + + + + {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( + + ))} + + + ); +} diff --git a/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx new file mode 100644 index 00000000..e9c95657 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx @@ -0,0 +1,132 @@ +import { Text } from "@/components/common/Text"; +import { useDownload } from "@/providers/DownloadProvider"; +import { router, useLocalSearchParams, useNavigation } from "expo-router"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { ScrollView, TouchableOpacity, View, Alert } from "react-native"; +import { EpisodeCard } from "@/components/downloads/EpisodeCard"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; +import { + SeasonDropdown, + SeasonIndexState, +} from "@/components/series/SeasonDropdown"; +import { storage } from "@/utils/mmkv"; +import { Ionicons } from "@expo/vector-icons"; + +export default function page() { + const navigation = useNavigation(); + const local = useLocalSearchParams(); + const { seriesId, episodeSeasonIndex } = local as { + seriesId: string; + episodeSeasonIndex: number | string | undefined; + }; + + const [seasonIndexState, setSeasonIndexState] = useState( + {} + ); + const { downloadedFiles, deleteItems } = useDownload(); + + const series = useMemo(() => { + try { + return ( + downloadedFiles + ?.filter((f) => f.item.SeriesId == seriesId) + ?.sort( + (a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber! + ) || [] + ); + } catch { + return []; + } + }, [downloadedFiles]); + + const seasonIndex = + seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || + episodeSeasonIndex || + ""; + + const groupBySeason = useMemo(() => { + const seasons: Record = {}; + + series?.forEach((episode) => { + if (!seasons[episode.item.ParentIndexNumber!]) { + seasons[episode.item.ParentIndexNumber!] = []; + } + + seasons[episode.item.ParentIndexNumber!].push(episode.item); + }); + return ( + seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ?? + [] + ); + }, [series, seasonIndex]); + + const initialSeasonIndex = useMemo( + () => + Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? + series?.[0]?.item?.ParentIndexNumber, + [groupBySeason] + ); + + useEffect(() => { + if (series.length > 0) { + navigation.setOptions({ + title: series[0].item.SeriesName, + }); + } else { + storage.delete(seriesId); + router.back(); + } + }, [series]); + + const deleteSeries = useCallback(() => { + Alert.alert( + "Delete season", + "Are you sure you want to delete the entire season?", + [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Delete", + onPress: () => deleteItems(groupBySeason), + style: "destructive", + }, + ] + ); + }, [groupBySeason]); + + return ( + + {series.length > 0 && ( + + s.item)} + state={seasonIndexState} + initialSeasonIndex={initialSeasonIndex!} + onSelect={(season) => { + setSeasonIndexState((prev) => ({ + ...prev, + [series[0].item.ParentId ?? ""]: season.ParentIndexNumber, + })); + }} + /> + + {groupBySeason.length} + + + + + + + + )} + + {groupBySeason.map((episode, index) => ( + + ))} + + + ); +} diff --git a/app/(auth)/(tabs)/(home)/downloads/index.tsx b/app/(auth)/(tabs)/(home)/downloads/index.tsx new file mode 100644 index 00000000..2d4dcaa5 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/downloads/index.tsx @@ -0,0 +1,231 @@ +import { Text } from "@/components/common/Text"; +import { ActiveDownloads } from "@/components/downloads/ActiveDownloads"; +import { MovieCard } from "@/components/downloads/MovieCard"; +import { SeriesCard } from "@/components/downloads/SeriesCard"; +import { DownloadedItem, useDownload } from "@/providers/DownloadProvider"; +import { queueAtom } from "@/utils/atoms/queue"; +import { useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; +import {useNavigation, useRouter} from "expo-router"; +import { useAtom } from "jotai"; +import React, {useEffect, useMemo, useRef} from "react"; +import {Alert, ScrollView, TouchableOpacity, View} from "react-native"; +import { Button } from "@/components/Button"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import {DownloadSize} from "@/components/downloads/DownloadSize"; +import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; +import {toast} from "sonner-native"; +import {writeToLog} from "@/utils/log"; + +export default function page() { + const navigation = useNavigation(); + const [queue, setQueue] = useAtom(queueAtom); + const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); + const router = useRouter(); + const [settings] = useSettings(); + const bottomSheetModalRef = useRef(null); + + const movies = useMemo(() => { + try { + return downloadedFiles?.filter((f) => f.item.Type === "Movie") || []; + } catch { + migration_20241124(); + return []; + } + }, [downloadedFiles]); + + const groupedBySeries = useMemo(() => { + try { + const episodes = downloadedFiles?.filter( + (f) => f.item.Type === "Episode" + ); + const series: { [key: string]: DownloadedItem[] } = {}; + episodes?.forEach((e) => { + if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = []; + series[e.item.SeriesName!].push(e); + }); + return Object.values(series); + } catch { + migration_20241124(); + return []; + } + }, [downloadedFiles]); + + const insets = useSafeAreaInsets(); + + useEffect(() => { + navigation.setOptions({ + headerRight: () => ( + + f.item) || []}/> + + ) + }) + }, [downloadedFiles]); + + const deleteMovies = () => deleteFileByType("Movie") + .then(() => toast.success("Deleted all movies successfully!")) + .catch((reason) => { + writeToLog("ERROR", reason); + toast.error("Failed to delete all movies"); + }); + const deleteShows = () => deleteFileByType("Episode") + .then(() => toast.success("Deleted all TV-Series successfully!")) + .catch((reason) => { + writeToLog("ERROR", reason); + toast.error("Failed to delete all TV-Series"); + }); + const deleteAllMedia = async () => await Promise.all([deleteMovies(), deleteShows()]) + + return ( + <> + + + + {settings?.downloadMethod === "remux" && ( + + Queue + + Queue and downloads will be lost on app restart + + + {queue.map((q, index) => ( + + 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} + > + + {q.item.Name} + {q.item.Type} + + { + removeProcess(q.id); + setQueue((prev) => { + if (!prev) return []; + return [...prev.filter((i) => i.id !== q.id)]; + }); + }} + > + + + + ))} + + + {queue.length === 0 && ( + No items in queue + )} + + )} + + + + + {movies.length > 0 && ( + + + Movies + + {movies?.length} + + + + + {movies?.map((item) => ( + + + + ))} + + + + )} + {groupedBySeries.length > 0 && ( + + + TV-Series + + {groupedBySeries?.length} + + + + + {groupedBySeries?.map((items) => ( + + i.item)} + key={items[0].item.SeriesId} + /> + + ))} + + + + )} + {downloadedFiles?.length === 0 && ( + + No downloaded items + + )} + + + ( + + )} + > + + + + + + + + + + ); +} + +function migration_20241124() { + const router = useRouter(); + const { deleteAllFiles } = useDownload(); + Alert.alert( + "New app version requires re-download", + "The new update reqires content to be downloaded again. Please remove all downloaded content and try again.", + [ + { + text: "Back", + onPress: () => router.back(), + }, + { + text: "Delete", + style: "destructive", + onPress: async () => await deleteAllFiles(), + }, + ] + ); +} diff --git a/app/(auth)/(tabs)/(home)/index.tsx b/app/(auth)/(tabs)/(home)/index.tsx new file mode 100644 index 00000000..b11e12f4 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/index.tsx @@ -0,0 +1,439 @@ +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel"; +import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { Loader } from "@/components/Loader"; +import { MediaListSection } from "@/components/medialists/MediaListSection"; +import { Colors } from "@/constants/Colors"; +import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; +import { useDownload } from "@/providers/DownloadProvider"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { Feather, Ionicons } from "@expo/vector-icons"; +import { Api } from "@jellyfin/sdk"; +import { + BaseItemDto, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getItemsApi, + getSuggestionsApi, + getTvShowsApi, + getUserLibraryApi, + getUserViewsApi, +} from "@jellyfin/sdk/lib/utils/api"; +import NetInfo from "@react-native-community/netinfo"; +import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigation, useRouter } from "expo-router"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + RefreshControl, + ScrollView, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +type ScrollingCollectionListSection = { + type: "ScrollingCollectionList"; + title?: string; + queryKey: (string | undefined | null)[]; + queryFn: QueryFunction; + orientation?: "horizontal" | "vertical"; +}; + +type MediaListSection = { + type: "MediaListSection"; + queryKey: (string | undefined)[]; + queryFn: QueryFunction; +}; + +type Section = ScrollingCollectionListSection | MediaListSection; + +export default function index() { + const router = useRouter(); + + const { i18n, t } = useTranslation(); + + const api = useAtomValue(apiAtom); + const user = useAtomValue(userAtom); + + const [loading, setLoading] = useState(false); + const [settings, _] = useSettings(); + + const [isConnected, setIsConnected] = useState(null); + const [loadingRetry, setLoadingRetry] = useState(false); + + const { downloadedFiles, cleanCacheDirectory } = useDownload(); + const navigation = useNavigation(); + + const insets = useSafeAreaInsets(); + + useEffect(() => { + const hasDownloads = downloadedFiles && downloadedFiles.length > 0; + navigation.setOptions({ + headerLeft: () => ( + { + router.push("/(auth)/downloads"); + }} + className="p-2" + > + + + ), + }); + }, [downloadedFiles, navigation, router]); + + const checkConnection = useCallback(async () => { + setLoadingRetry(true); + const state = await NetInfo.fetch(); + setIsConnected(state.isConnected); + setLoadingRetry(false); + }, []); + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state) => { + if (state.isConnected == false || state.isInternetReachable === false) + setIsConnected(false); + else setIsConnected(true); + }); + + NetInfo.fetch().then((state) => { + setIsConnected(state.isConnected); + }); + + cleanCacheDirectory() + .then(r => console.log("Cache directory cleaned")) + .catch(e => console.error("Something went wrong cleaning cache directory")) + return () => { + unsubscribe(); + }; + }, []); + + const { + data: userViews, + isError: e1, + isLoading: l1, + } = useQuery({ + queryKey: ["home", "userViews", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) { + return null; + } + + const response = await getUserViewsApi(api).getUserViews({ + userId: user.Id, + }); + + return response.data.Items || null; + }, + enabled: !!api && !!user?.Id, + staleTime: 60 * 1000, + }); + + const { + data: mediaListCollections, + isError: e2, + isLoading: l2, + } = useQuery({ + queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin], + queryFn: async () => { + if (!api || !user?.Id) return []; + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + tags: ["sf_promoted"], + recursive: true, + fields: ["Tags"], + includeItemTypes: ["BoxSet"], + }); + + return response.data.Items || []; + }, + enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true, + staleTime: 60 * 1000, + }); + + const collections = useMemo(() => { + const allow = ["movies", "tvshows"]; + return ( + userViews?.filter( + (c) => c.CollectionType && allow.includes(c.CollectionType) + ) || [] + ); + }, [userViews]); + + const invalidateCache = useInvalidatePlaybackProgressCache(); + + const refetch = useCallback(async () => { + setLoading(true); + await invalidateCache(); + setLoading(false); + }, []); + + const createCollectionConfig = useCallback( + ( + title: string, + queryKey: string[], + includeItemTypes: BaseItemKind[], + parentId: string | undefined + ): ScrollingCollectionListSection => ({ + title, + queryKey, + queryFn: async () => { + if (!api) return []; + return ( + ( + await getUserLibraryApi(api).getLatestMedia({ + userId: user?.Id, + limit: 20, + fields: ["PrimaryImageAspectRatio", "Path"], + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes, + parentId, + }) + ).data || [] + ); + }, + type: "ScrollingCollectionList", + }), + [api, user?.Id] + ); + + const sections = useMemo(() => { + if (!api || !user?.Id) return []; + + const latestMediaViews = collections.map((c) => { + const includeItemTypes: BaseItemKind[] = + c.CollectionType === "tvshows" ? ["Series"] : ["Movie"]; + const title = t("recentlyAdded" + c.Name); + const queryKey = [ + "home", + "recentlyAddedIn" + c.CollectionType, + user?.Id!, + c.Id!, + ]; + return createCollectionConfig( + title || "", + queryKey, + includeItemTypes, + c.Id + ); + }); + + const ss: Section[] = [ + { + title: t("home.continueWatching"), + queryKey: ["home", "resumeItems"], + queryFn: async () => + ( + await getItemsApi(api).getResumeItems({ + userId: user.Id, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + includeItemTypes: ["Movie", "Series", "Episode"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + { + title: t("home.nextUp"), + queryKey: ["home", "nextUp-all"], + queryFn: async () => + ( + await getTvShowsApi(api).getNextUp({ + userId: user?.Id, + fields: ["MediaSourceCount"], + limit: 20, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + enableResumable: false, + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ...latestMediaViews, + ...(mediaListCollections?.map( + (ml) => + ({ + title: ml.Name, + queryKey: ["home", "mediaList", ml.Id!], + queryFn: async () => ml, + type: "MediaListSection", + orientation: "vertical", + } as Section) + ) || []), + { + title: t("home.suggestedMovies"), + queryKey: ["home", "suggestedMovies", user?.Id], + queryFn: async () => + ( + await getSuggestionsApi(api).getSuggestions({ + userId: user?.Id, + limit: 10, + mediaType: ["Video"], + type: ["Movie"], + }) + ).data.Items || [], + type: "ScrollingCollectionList", + orientation: "vertical", + }, + { + title: t("home.suggestedEpisodes"), + queryKey: ["home", "suggestedEpisodes", user?.Id], + queryFn: async () => { + try { + const suggestions = await getSuggestions(api, user.Id); + const nextUpPromises = suggestions.map((series) => + getNextUp(api, user.Id, series.Id) + ); + const nextUpResults = await Promise.all(nextUpPromises); + + return nextUpResults.filter((item) => item !== null) || []; + } catch (error) { + console.error("Error fetching data:", error); + return []; + } + }, + type: "ScrollingCollectionList", + orientation: "horizontal", + }, + ]; + return ss; + }, [api, user?.Id, collections, mediaListCollections]); + + if (isConnected === false) { + return ( + + {t("home.noInternet")} + + {t("home.noInternetMessage")} + + + + + + + ); + } + + if (e1 || e2) + return ( + + {t("home.oops")} + {t("home.errorMessage")} + + ); + + if (l1 || l2) + return ( + + + + ); + + return ( + + } + contentContainerStyle={{ + paddingLeft: insets.left, + paddingRight: insets.right, + paddingBottom: 16, + }} + > + + + + {sections.map((section, index) => { + if (section.type === "ScrollingCollectionList") { + return ( + + ); + } else if (section.type === "MediaListSection") { + return ( + + ); + } + return null; + })} + + + ); +} + +// Function to get suggestions +async function getSuggestions(api: Api, userId: string | undefined) { + if (!userId) return []; + const response = await getSuggestionsApi(api).getSuggestions({ + userId, + limit: 10, + mediaType: ["Unknown"], + type: ["Series"], + }); + return response.data.Items ?? []; +} + +// Function to get the next up TV show for a series +async function getNextUp( + api: Api, + userId: string | undefined, + seriesId: string | undefined +) { + if (!userId || !seriesId) return null; + const response = await getTvShowsApi(api).getNextUp({ + userId, + seriesId, + limit: 1, + }); + return response.data.Items?.[0] ?? null; +} diff --git a/app/(auth)/(tabs)/(home)/settings.tsx b/app/(auth)/(tabs)/(home)/settings.tsx new file mode 100644 index 00000000..46aecbae --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings.tsx @@ -0,0 +1,177 @@ +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { ListItem } from "@/components/ListItem"; +import { SettingToggles } from "@/components/settings/SettingToggles"; +import {useDownload} from "@/providers/DownloadProvider"; +import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; +import { clearLogs, useLog } from "@/utils/log"; +import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import * as FileSystem from "expo-file-system"; +import * as Haptics from "expo-haptics"; +import { useAtom } from "jotai"; +import { Alert, ScrollView, View } from "react-native"; +import * as Progress from "react-native-progress"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { toast } from "sonner-native"; + +export default function settings() { + const { logout } = useJellyfin(); + const { deleteAllFiles, appSizeUsage } = useDownload(); + const { logs } = useLog(); + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const insets = useSafeAreaInsets(); + + const { data: size, isLoading: appSizeLoading } = useQuery({ + queryKey: ["appSize", appSizeUsage], + queryFn: async () => { + const app = await appSizeUsage; + + const remaining = await FileSystem.getFreeDiskStorageAsync(); + const total = await FileSystem.getTotalDiskCapacityAsync(); + + return { app, remaining, total, used: (total - remaining) / total }; + }, + }); + + const openQuickConnectAuthCodeInput = () => { + Alert.prompt( + "Quick connect", + "Enter the quick connect code", + async (text) => { + if (text) { + try { + const res = await getQuickConnectApi(api!).authorizeQuickConnect({ + code: text, + userId: user?.Id, + }); + if (res.status === 200) { + Haptics.notificationAsync( + Haptics.NotificationFeedbackType.Success + ); + Alert.alert("Success", "Quick connect authorized"); + } else { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert("Error", "Invalid code"); + } + } catch (e) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert("Error", "Invalid code"); + } + } + } + ); + }; + + const onDeleteClicked = async () => { + try { + await deleteAllFiles(); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (e) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + toast.error("Error deleting files"); + } + }; + + const onClearLogsClicked = async () => { + clearLogs(); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }; + + return ( + + + {/* */} + + User Info + + + + + + + + + + + Quick connect + + + + + + + Storage + + {size && App usage: {size.app.bytesToReadable()}} + + {size && ( + + Available: {size.remaining?.bytesToReadable()}, Total:{" "} + {size.total?.bytesToReadable()} + + )} + + + + + + Logs + + {logs?.map((log, index) => ( + + + {log.level} + + + {log.message} + + + ))} + {logs?.length === 0 && ( + No logs available + )} + + + + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx new file mode 100644 index 00000000..45dc8a4d --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/actors/[actorId].tsx @@ -0,0 +1,140 @@ +import { ItemCardText } from "@/components/ItemCardText"; +import { Loader } from "@/components/Loader"; +import { OverviewText } from "@/components/OverviewText"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll"; +import { Text } from "@/components/common/Text"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; +import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader"; +import MoviePoster from "@/components/posters/MoviePoster"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { Image } from "expo-image"; +import { useLocalSearchParams } from "expo-router"; +import { useAtom } from "jotai"; +import { useCallback, useMemo } from "react"; +import { View } from "react-native"; + +const page: React.FC = () => { + const local = useLocalSearchParams(); + const { actorId } = local as { actorId: string }; + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const { data: item, isLoading: l1 } = useQuery({ + queryKey: ["item", actorId], + queryFn: async () => + await getUserItemData({ + api, + userId: user?.Id, + itemId: actorId, + }), + enabled: !!actorId && !!api, + staleTime: 60, + }); + + const fetchItems = useCallback( + async ({ + pageParam, + }: { + pageParam: number; + }): Promise => { + if (!api || !user?.Id) return null; + + const response = await getItemsApi(api).getItems({ + userId: user.Id, + personIds: [actorId], + startIndex: pageParam, + limit: 16, + sortOrder: ["Descending", "Descending", "Ascending"], + includeItemTypes: ["Movie", "Series"], + recursive: true, + fields: [ + "ParentId", + "PrimaryImageAspectRatio", + "ParentId", + "PrimaryImageAspectRatio", + ], + sortBy: ["PremiereDate", "ProductionYear", "SortName"], + collapseBoxSetItems: false, + }); + + return response.data; + }, + [api, user?.Id, actorId] + ); + + const backdropUrl = useMemo( + () => + getBackdropUrl({ + api, + item, + quality: 90, + width: 1000, + }), + [item] + ); + + if (l1) + return ( + + + + ); + + if (!item?.Id || !backdropUrl) return null; + + return ( + + } + > + + + + + + + + Appeared In + + ( + + + + + + + )} + queryFn={fetchItems} + queryKey={["actor", "movies", actorId]} + /> + + + + ); +}; + +export default page; diff --git a/app/(auth)/albums/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx similarity index 73% rename from app/(auth)/albums/[albumId].tsx rename to app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx index 0a4a991d..565f84c8 100644 --- a/app/(auth)/albums/[albumId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/albums/[albumId].tsx @@ -1,6 +1,9 @@ import { Chromecast } from "@/components/Chromecast"; +import { ItemImage } from "@/components/common/ItemImage"; import { Text } from "@/components/common/Text"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { SongsList } from "@/components/music/SongsList"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; import ArtistPoster from "@/components/posters/ArtistPoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; @@ -10,6 +13,7 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useState } from "react"; import { ScrollView, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; export default function page() { const searchParams = useLocalSearchParams(); @@ -87,35 +91,31 @@ export default function page() { enabled: !!api && !!user?.Id, }); + const insets = useSafeAreaInsets(); + if (!album) return null; return ( - - - - - - - - {album?.Name} - {album?.ProductionYear} - - - {album.AlbumArtists?.map((a) => ( - { - router.push(`/artists/${a.Id}/page`); - }} - > - - {album?.AlbumArtist} - - - ))} - - - + + } + > + + {album?.Name} + + {songs?.TotalRecordCount} songs + + + - + ); } diff --git a/app/(auth)/artists/[artistId]/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx similarity index 64% rename from app/(auth)/artists/[artistId]/page.tsx rename to app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx index bc2fa5f9..8d82d205 100644 --- a/app/(auth)/artists/[artistId]/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/artists/[artistId].tsx @@ -8,6 +8,10 @@ import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useEffect, useState } from "react"; import { FlatList, ScrollView, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ItemImage } from "@/components/common/ItemImage"; +import { ParallaxScrollView } from "@/components/ParallaxPage"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; export default function page() { const searchParams = useLocalSearchParams(); @@ -82,50 +86,45 @@ export default function page() { enabled: !!api && !!user?.Id, }); - useEffect(() => { - navigation.setOptions({ - title: albums?.Items?.[0].AlbumArtist, - }); - }, [albums]); + const insets = useSafeAreaInsets(); if (!artist || !albums) return null; return ( - - - - - Albums - - } - nestedScrollEnabled - data={albums.Items} - numColumns={3} - columnWrapperStyle={{ - justifyContent: "space-between", - }} - renderItem={({ item, index }) => ( - { - router.push(`/albums/${item.Id}`); + - - - {item.Name} - {item.ProductionYear} - - - )} - keyExtractor={(item) => item.Id || ""} - /> + /> + } + > + + {artist?.Name} + + {albums.TotalRecordCount} albums + + + + {albums.Items.map((item, idx) => ( + + + + {item.Name} + {item.ProductionYear} + + + ))} + + ); } diff --git a/app/(auth)/artists/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/artists/index.tsx similarity index 95% rename from app/(auth)/artists/page.tsx rename to app/(auth)/(tabs)/(home,libraries,search)/artists/index.tsx index 3c3b6c8b..4827287e 100644 --- a/app/(auth)/artists/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/artists/index.tsx @@ -1,4 +1,5 @@ import { Text } from "@/components/common/Text"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import ArtistPoster from "@/components/posters/ArtistPoster"; import MoviePoster from "@/components/posters/MoviePoster"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; @@ -90,15 +91,13 @@ export default function page() { justifyContent: "space-between", }} renderItem={({ item, index }) => ( - { - router.push(`/artists/${item.Id}/page`); - }} + item={item} > {collection?.CollectionType === "movies" && ( @@ -110,7 +109,7 @@ export default function page() { {item.Name} {item.ProductionYear} - + )} keyExtractor={(item) => item.Id || ""} /> diff --git a/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx new file mode 100644 index 00000000..4c2b72ae --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/collections/[collectionId].tsx @@ -0,0 +1,415 @@ +import { Text } from "@/components/common/Text"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; +import { FilterButton } from "@/components/filters/FilterButton"; +import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; +import { ItemCardText } from "@/components/ItemCardText"; +import { ItemPoster } from "@/components/posters/ItemPoster"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + genreFilterAtom, + sortByAtom, + SortByOption, + sortOptions, + sortOrderAtom, + SortOrderOption, + sortOrderOptions, + tagsFilterAtom, + yearFilterAtom, +} from "@/utils/atoms/filters"; +import { + BaseItemDto, + BaseItemDtoQueryResult, + ItemSortBy, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getFilterApi, + getItemsApi, + getUserLibraryApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import * as ScreenOrientation from "expo-screen-orientation"; +import { useAtom } from "jotai"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { FlatList, View } from "react-native"; + +const page: React.FC = () => { + const searchParams = useLocalSearchParams(); + const { collectionId } = searchParams as { collectionId: string }; + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const navigation = useNavigation(); + const [orientation, setOrientation] = useState( + ScreenOrientation.Orientation.PORTRAIT_UP + ); + + const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); + const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); + const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); + const [sortBy, setSortBy] = useAtom(sortByAtom); + const [sortOrder, setSortOrder] = useAtom(sortOrderAtom); + + const { data: collection } = useQuery({ + queryKey: ["collection", collectionId], + queryFn: async () => { + if (!api) return null; + const response = await getUserLibraryApi(api).getItem({ + itemId: collectionId, + userId: user?.Id, + }); + const data = response.data; + return data; + }, + enabled: !!api && !!user?.Id && !!collectionId, + staleTime: 60 * 1000, + }); + + useEffect(() => { + navigation.setOptions({ title: collection?.Name || "" }); + setSortOrder([SortOrderOption.Ascending]); + + if (!collection) return; + + // Convert the DisplayOrder to SortByOption + const displayOrder = collection.DisplayOrder as ItemSortBy; + const sortByOption = displayOrder + ? SortByOption[displayOrder as keyof typeof SortByOption] || + SortByOption.PremiereDate + : SortByOption.PremiereDate; + + setSortBy([sortByOption]); + }, [navigation, collection]); + + const fetchItems = useCallback( + async ({ + pageParam, + }: { + pageParam: number; + }): Promise => { + if (!api || !collection) return null; + + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + parentId: collectionId, + limit: 18, + startIndex: pageParam, + // Set one ordering at a time. As collections do not work with correctly with multiple. + sortBy: [sortBy[0]], + sortOrder: [sortOrder[0]], + fields: [ + "ItemCounts", + "PrimaryImageAspectRatio", + "CanDelete", + "MediaSourceCount", + ], + // true is needed for merged versions + recursive: true, + genres: selectedGenres, + tags: selectedTags, + years: selectedYears.map((year) => parseInt(year)), + includeItemTypes: ["Movie", "Series", "MusicAlbum"], + }); + + return response.data || null; + }, + [ + api, + user?.Id, + collection, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ] + ); + + const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ + queryKey: [ + "collection-items", + collection, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ], + queryFn: fetchItems, + getNextPageParam: (lastPage, pages) => { + if ( + !lastPage?.Items || + !lastPage?.TotalRecordCount || + lastPage?.TotalRecordCount === 0 + ) + return undefined; + + const totalItems = lastPage.TotalRecordCount; + const accumulatedItems = pages.reduce( + (acc, curr) => acc + (curr?.Items?.length || 0), + 0 + ); + + if (accumulatedItems < totalItems) { + return lastPage?.Items?.length * pages.length; + } else { + return undefined; + } + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!collection, + }); + + const flatData = useMemo(() => { + return ( + (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) || + [] + ); + }, [data]); + + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + + + + {/* */} + + + + ), + [orientation] + ); + + const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + + const ListHeaderComponent = useCallback( + () => ( + + , + }, + { + key: "genre", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Genres || []; + }} + set={setSelectedGenres} + values={selectedGenres} + title="Genres" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "year", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Years || []; + }} + set={setSelectedYears} + values={selectedYears} + title="Years" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => item.includes(search)} + /> + ), + }, + { + key: "tags", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: collectionId, + }); + return response.data.Tags || []; + }} + set={setSelectedTags} + values={selectedTags} + title="Tags" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortBy", + component: ( + sortOptions.map((s) => s.key)} + set={setSortBy} + values={sortBy} + title="Sort By" + renderItemLabel={(item) => + sortOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortOrder", + component: ( + sortOrderOptions.map((s) => s.key)} + set={setSortOrder} + values={sortOrder} + title="Sort Order" + renderItemLabel={(item) => + sortOrderOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + ]} + renderItem={({ item }) => item.component} + keyExtractor={(item) => item.key} + /> + + ), + [ + collectionId, + api, + user?.Id, + selectedGenres, + setSelectedGenres, + selectedYears, + setSelectedYears, + selectedTags, + setSelectedTags, + sortBy, + setSortBy, + sortOrder, + setSortOrder, + isFetching, + ] + ); + + if (!collection) return null; + + return ( + + No results + + } + extraData={[ + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ]} + contentInsetAdjustmentBehavior="automatic" + data={flatData} + renderItem={renderItem} + keyExtractor={keyExtractor} + estimatedItemSize={255} + numColumns={ + orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5 + } + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + ListHeaderComponent={ListHeaderComponent} + contentContainerStyle={{ paddingBottom: 24 }} + ItemSeparatorComponent={() => ( + + )} + /> + ); +}; + +export default page; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx new file mode 100644 index 00000000..38b0115d --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/items/page.tsx @@ -0,0 +1,112 @@ +import { Text } from "@/components/common/Text"; +import { ItemContent } from "@/components/ItemContent"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useLocalSearchParams } from "expo-router"; +import { useAtom } from "jotai"; +import React, { useEffect } from "react"; +import { View } from "react-native"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; + +const Page: React.FC = () => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { id } = useLocalSearchParams() as { id: string }; + + const { data: item, isError } = useQuery({ + queryKey: ["item", id], + queryFn: async () => { + if (!api || !user || !id) return; + const res = await getUserLibraryApi(api).getItem({ + itemId: id, + userId: user?.Id, + }); + + return res.data; + }, + staleTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }); + + const opacity = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); + + const fadeOut = (callback: any) => { + setTimeout(() => { + opacity.value = withTiming(0, { duration: 500 }, (finished) => { + if (finished) { + runOnJS(callback)(); + } + }); + }, 100); + }; + + const fadeIn = (callback: any) => { + setTimeout(() => { + opacity.value = withTiming(1, { duration: 500 }, (finished) => { + if (finished) { + runOnJS(callback)(); + } + }); + }, 100); + }; + + useEffect(() => { + if (item) { + fadeOut(() => {}); + } else { + fadeIn(() => {}); + } + }, [item]); + + if (isError) + return ( + + Could not load item + + ); + + return ( + + + + + + + + + + + + + + + + + {item && } + + ); +}; + +export default Page; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx b/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx new file mode 100644 index 00000000..bd778042 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/jellyseerr/page.tsx @@ -0,0 +1,251 @@ +import React, {useCallback, useRef, useState} from "react"; +import {useLocalSearchParams} from "expo-router"; +import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; +import {Text} from "@/components/common/Text"; +import {ParallaxScrollView} from "@/components/ParallaxPage"; +import {Image} from "expo-image"; +import {TouchableOpacity, View} from "react-native"; +import {Ionicons} from "@expo/vector-icons"; +import {useSafeAreaInsets} from "react-native-safe-area-context"; +import {OverviewText} from "@/components/OverviewText"; +import {GenreTags} from "@/components/GenreTags"; +import {MediaType} from "@/utils/jellyseerr/server/constants/media"; +import {useQuery} from "@tanstack/react-query"; +import {useJellyseerr} from "@/hooks/useJellyseerr"; +import {Button} from "@/components/Button"; +import {BottomSheetBackdrop, BottomSheetBackdropProps, BottomSheetModal, BottomSheetView} from "@gorhom/bottom-sheet"; +import {IssueType, IssueTypeName} from "@/utils/jellyseerr/server/constants/issue"; +import * as DropdownMenu from "zeego/dropdown-menu"; +import {Input} from "@/components/common/Input"; +import {TvDetails} from "@/utils/jellyseerr/server/models/Tv"; +import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; +import {JellyserrRatings} from "@/components/Ratings"; + +const Page: React.FC = () => { + const insets = useSafeAreaInsets(); + const params = useLocalSearchParams(); + const {mediaTitle, releaseYear, canRequest: canRequestString, posterSrc, ...result} = + params as unknown as {mediaTitle: string, releaseYear: number, canRequest: string, posterSrc: string} & Partial; + + const canRequest = canRequestString === "true"; + const {jellyseerrApi, requestMedia} = useJellyseerr(); + + const [issueType, setIssueType] = useState(); + const [issueMessage, setIssueMessage] = useState(); + const bottomSheetModalRef = useRef(null); + + const {data: details, isLoading} = useQuery({ + enabled: !!jellyseerrApi && !!result && !!result.id, + queryKey: ["jellyseerr", "detail", result.mediaType, result.id], + staleTime: 0, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + retryOnMount: true, + queryFn: async () => { + return result.mediaType === MediaType.MOVIE + ? jellyseerrApi?.movieDetails(result.id!!) + : jellyseerrApi?.tvDetails(result.id!!) + } + }); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [] + ); + + const submitIssue = useCallback(() => { + if (result.id && issueType && issueMessage && details) { + jellyseerrApi?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage) + .then(() => { + setIssueType(undefined) + setIssueMessage(undefined) + bottomSheetModalRef?.current?.close() + }) + } + }, [jellyseerrApi, details, result, issueType, issueMessage]) + + const request = useCallback(() => requestMedia(mediaTitle, { + mediaId: Number(result.id!!), + mediaType: result.mediaType!!, + tvdbId: details?.externalIds?.tvdbId, + seasons: (details as TvDetails)?.seasons.filter(s => s.seasonNumber !== 0).map(s => s.seasonNumber) + }), [details, result, requestMedia]); + + return ( + + + {result.backdropPath ? ( + + ) : ( + + + + )} + + } + > + + + <> + + + + {mediaTitle} + {releaseYear} + + + + + g.name) || []} /> + {canRequest ? + + : + + } + + + {result.mediaType === MediaType.TV && + + } + + + + + + + + Whats wrong? + + + + + + + Issue Type + + + {issueType ? IssueTypeName[issueType] : 'Select an issue' } + + + + + + Types + {Object.entries(IssueTypeName).reverse().map(([key, value], idx) => ( + setIssueType(key as unknown as IssueType)} + > + {value} + + ))} + + + + + + + + + + + + ); +} + +export default Page; \ No newline at end of file diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx new file mode 100644 index 00000000..7225e677 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/_layout.tsx @@ -0,0 +1,49 @@ +import type { + MaterialTopTabNavigationEventMap, + MaterialTopTabNavigationOptions, +} from "@react-navigation/material-top-tabs"; +import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; +import { ParamListBase, TabNavigationState } from "@react-navigation/native"; +import { Stack, withLayoutContext } from "expo-router"; +import React from "react"; + +const { Navigator } = createMaterialTopTabNavigator(); + +export const Tab = withLayoutContext< + MaterialTopTabNavigationOptions, + typeof Navigator, + TabNavigationState, + MaterialTopTabNavigationEventMap +>(Navigator); + +const Layout = () => { + return ( + <> + + + + + + + + + ); +}; + +export default Layout; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx new file mode 100644 index 00000000..dd1c1f85 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/channels.tsx @@ -0,0 +1,56 @@ +import { ItemImage } from "@/components/common/ItemImage"; +import { Text } from "@/components/common/Text"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import React from "react"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export default function page() { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + + const { data: channels } = useQuery({ + queryKey: ["livetv", "channels"], + queryFn: async () => { + const res = await getLiveTvApi(api!).getLiveTvChannels({ + startIndex: 0, + limit: 500, + enableFavoriteSorting: true, + userId: user?.Id, + addCurrentProgram: false, + enableUserData: false, + enableImageTypes: ["Primary"], + }); + return res.data; + }, + }); + + return ( + + ( + + + + + {item.Name} + + )} + /> + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx new file mode 100644 index 00000000..01652b5f --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/guide.tsx @@ -0,0 +1,219 @@ +import { ItemImage } from "@/components/common/ItemImage"; +import { Text } from "@/components/common/Text"; +import { HourHeader } from "@/components/livetv/HourHeader"; +import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow"; +import { TAB_HEIGHT } from "@/constants/Values"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { Ionicons } from "@expo/vector-icons"; +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import React, { useCallback, useMemo, useState } from "react"; +import { + Button, + Dimensions, + ScrollView, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +const HOUR_HEIGHT = 30; +const ITEMS_PER_PAGE = 20; + +const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow); + +export default function page() { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + const [date, setDate] = useState(new Date()); + const [currentPage, setCurrentPage] = useState(1); + + const { data: guideInfo } = useQuery({ + queryKey: ["livetv", "guideInfo"], + queryFn: async () => { + const res = await getLiveTvApi(api!).getGuideInfo(); + return res.data; + }, + }); + + const { data: channels } = useQuery({ + queryKey: ["livetv", "channels", currentPage], + queryFn: async () => { + const res = await getLiveTvApi(api!).getLiveTvChannels({ + startIndex: (currentPage - 1) * ITEMS_PER_PAGE, + limit: ITEMS_PER_PAGE, + enableFavoriteSorting: true, + userId: user?.Id, + addCurrentProgram: false, + enableUserData: false, + enableImageTypes: ["Primary"], + }); + return res.data; + }, + }); + + const { data: programs } = useQuery({ + queryKey: ["livetv", "programs", date, currentPage], + queryFn: async () => { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + const now = new Date(); + const isToday = startOfDay.toDateString() === now.toDateString(); + + const res = await getLiveTvApi(api!).getPrograms({ + getProgramsDto: { + MaxStartDate: endOfDay.toISOString(), + MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(), + ChannelIds: channels?.Items?.map((c) => c.Id).filter( + Boolean + ) as string[], + ImageTypeLimit: 1, + EnableImages: false, + SortBy: ["StartDate"], + EnableTotalRecordCount: false, + EnableUserData: false, + }, + }); + return res.data; + }, + enabled: !!channels, + }); + + const screenWidth = Dimensions.get("window").width; + + const [scrollX, setScrollX] = useState(0); + + const handleNextPage = useCallback(() => { + setCurrentPage((prev) => prev + 1); + }, []); + + const handlePrevPage = useCallback(() => { + setCurrentPage((prev) => Math.max(1, prev - 1)); + }, []); + + return ( + + + + + + + {channels?.Items?.map((c, i) => ( + + + + ))} + + { + setScrollX(e.nativeEvent.contentOffset.x); + }} + > + + + {channels?.Items?.map((c, i) => ( + + ))} + + + + + ); +} + +interface PageButtonsProps { + currentPage: number; + onPrevPage: () => void; + onNextPage: () => void; + isNextDisabled: boolean; +} + +const PageButtons: React.FC = ({ + currentPage, + onPrevPage, + onNextPage, + isNextDisabled, +}) => { + return ( + + + + + Previous + + + Page {currentPage} + + + Next + + + + + ); +}; diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx new file mode 100644 index 00000000..fe62d313 --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/programs.tsx @@ -0,0 +1,144 @@ +import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; +import { TAB_HEIGHT } from "@/constants/Values"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; +import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; +import { useAtom } from "jotai"; +import React from "react"; +import { ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export default function page() { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const insets = useSafeAreaInsets(); + + return ( + + + { + if (!api) return [] as BaseItemDto[]; + const res = await getLiveTvApi(api).getRecommendedPrograms({ + userId: user?.Id, + isAiring: true, + limit: 24, + imageTypeLimit: 1, + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + enableTotalRecordCount: false, + fields: ["ChannelInfo", "PrimaryImageAspectRatio"], + }); + return res.data.Items || []; + }} + orientation="horizontal" + /> + { + if (!api) return [] as BaseItemDto[]; + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user?.Id, + hasAired: false, + limit: 9, + isMovie: false, + isSeries: true, + isSports: false, + isNews: false, + isKids: false, + enableTotalRecordCount: false, + fields: ["ChannelInfo", "PrimaryImageAspectRatio"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + return res.data.Items || []; + }} + orientation="horizontal" + /> + { + if (!api) return [] as BaseItemDto[]; + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user?.Id, + hasAired: false, + limit: 9, + isMovie: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + return res.data.Items || []; + }} + orientation="horizontal" + /> + { + if (!api) return [] as BaseItemDto[]; + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user?.Id, + hasAired: false, + limit: 9, + isSports: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + return res.data.Items || []; + }} + orientation="horizontal" + /> + { + if (!api) return [] as BaseItemDto[]; + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user?.Id, + hasAired: false, + limit: 9, + isKids: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + return res.data.Items || []; + }} + orientation="horizontal" + /> + { + if (!api) return [] as BaseItemDto[]; + const res = await getLiveTvApi(api).getLiveTvPrograms({ + userId: user?.Id, + hasAired: false, + limit: 9, + isNews: true, + enableTotalRecordCount: false, + fields: ["ChannelInfo"], + enableImageTypes: ["Primary", "Thumb", "Backdrop"], + }); + return res.data.Items || []; + }} + orientation="horizontal" + /> + + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search)/livetv/recordings.tsx b/app/(auth)/(tabs)/(home,libraries,search)/livetv/recordings.tsx new file mode 100644 index 00000000..6e3f660e --- /dev/null +++ b/app/(auth)/(tabs)/(home,libraries,search)/livetv/recordings.tsx @@ -0,0 +1,11 @@ +import { Text } from "@/components/common/Text"; +import React from "react"; +import { View } from "react-native"; + +export default function page() { + return ( + + Coming soon + + ); +} diff --git a/app/(auth)/series/[id].tsx b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx similarity index 58% rename from app/(auth)/series/[id].tsx rename to app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx index d3481e6e..ecee672b 100644 --- a/app/(auth)/series/[id].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search)/series/[id].tsx @@ -1,4 +1,5 @@ import { Text } from "@/components/common/Text"; +import { DownloadItems } from "@/components/DownloadItem"; import { ParallaxScrollView } from "@/components/ParallaxPage"; import { NextUp } from "@/components/series/NextUp"; import { SeasonPicker } from "@/components/series/SeasonPicker"; @@ -6,14 +7,17 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { Ionicons } from "@expo/vector-icons"; +import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api"; import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; -import { useEffect, useMemo } from "react"; +import React, { useEffect, useMemo } from "react"; import { View } from "react-native"; const page: React.FC = () => { + const navigation = useNavigation(); const params = useLocalSearchParams(); const { id: seriesId, seasonIndex } = params as { id: string; @@ -43,7 +47,7 @@ const page: React.FC = () => { quality: 90, width: 1000, }), - [item], + [item] ); const logoUrl = useMemo( @@ -52,13 +56,54 @@ const page: React.FC = () => { api, item, }), - [item], + [item] ); + const { data: allEpisodes, isLoading } = useQuery({ + queryKey: ["AllEpisodes", item?.Id], + queryFn: async () => { + const res = await getTvShowsApi(api!).getEpisodes({ + seriesId: item?.Id!, + userId: user?.Id!, + enableUserData: true, + fields: ["MediaSources", "MediaStreams", "Overview"], + }); + return res?.data.Items || []; + }, + enabled: !!api && !!user?.Id && !!item?.Id, + }); + + useEffect(() => { + navigation.setOptions({ + headerRight: () => + !isLoading && + allEpisodes && + allEpisodes.length > 0 && ( + + ( + + )} + DownloadedIconComponent={() => ( + + )} + /> + + ), + }); + }, [allEpisodes, isLoading]); + if (!item || !backdropUrl) return null; return ( { } > - + {item?.Name} {item?.Overview} @@ -95,7 +140,7 @@ const page: React.FC = () => { - + ); diff --git a/app/(auth)/(tabs)/(libraries)/[libraryId].tsx b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx new file mode 100644 index 00000000..7d5679a1 --- /dev/null +++ b/app/(auth)/(tabs)/(libraries)/[libraryId].tsx @@ -0,0 +1,483 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { useLocalSearchParams, useNavigation } from "expo-router"; +import * as ScreenOrientation from "expo-screen-orientation"; +import { useAtom } from "jotai"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { FlatList, useWindowDimensions, View } from "react-native"; + +import { Text } from "@/components/common/Text"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; +import { FilterButton } from "@/components/filters/FilterButton"; +import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; +import { ItemCardText } from "@/components/ItemCardText"; +import { Loader } from "@/components/Loader"; +import { ItemPoster } from "@/components/posters/ItemPoster"; +import { useOrientation } from "@/hooks/useOrientation"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + genreFilterAtom, + getSortByPreference, + getSortOrderPreference, + sortByAtom, + SortByOption, + sortByPreferenceAtom, + sortOptions, + sortOrderAtom, + SortOrderOption, + sortOrderOptions, + sortOrderPreferenceAtom, + tagsFilterAtom, + yearFilterAtom, +} from "@/utils/atoms/filters"; +import { + BaseItemDto, + BaseItemDtoQueryResult, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { + getFilterApi, + getItemsApi, + getUserLibraryApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType"; + +const Page = () => { + const searchParams = useLocalSearchParams(); + const { libraryId } = searchParams as { libraryId: string }; + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const { width: screenWidth } = useWindowDimensions(); + + const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); + const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); + const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); + const [sortBy, _setSortBy] = useAtom(sortByAtom); + const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom); + const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom); + const [sortOrderPreference, setOderByPreference] = useAtom( + sortOrderPreferenceAtom + ); + + const { orientation } = useOrientation(); + + useEffect(() => { + const sop = getSortOrderPreference(libraryId, sortOrderPreference); + if (sop) { + _setSortOrder([sop]); + } else { + _setSortOrder([SortOrderOption.Ascending]); + } + const obp = getSortByPreference(libraryId, sortByPreference); + if (obp) { + _setSortBy([obp]); + } else { + _setSortBy([SortByOption.SortName]); + } + }, []); + + const setSortBy = useCallback( + (sortBy: SortByOption[]) => { + const sop = getSortByPreference(libraryId, sortByPreference); + if (sortBy[0] !== sop) { + setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] }); + } + _setSortBy(sortBy); + }, + [libraryId, sortByPreference] + ); + + const setSortOrder = useCallback( + (sortOrder: SortOrderOption[]) => { + const sop = getSortOrderPreference(libraryId, sortOrderPreference); + if (sortOrder[0] !== sop) { + setOderByPreference({ + ...sortOrderPreference, + [libraryId]: sortOrder[0], + }); + } + _setSortOrder(sortOrder); + }, + [libraryId, sortOrderPreference] + ); + + const nrOfCols = useMemo(() => { + if (screenWidth < 300) return 2; + if (screenWidth < 500) return 3; + if (screenWidth < 800) return 5; + if (screenWidth < 1000) return 6; + if (screenWidth < 1500) return 7; + return 6; + }, [screenWidth, orientation]); + + const { data: library, isLoading: isLibraryLoading } = useQuery({ + queryKey: ["library", libraryId], + queryFn: async () => { + if (!api) return null; + const response = await getUserLibraryApi(api).getItem({ + itemId: libraryId, + userId: user?.Id, + }); + return response.data; + }, + enabled: !!api && !!user?.Id && !!libraryId, + staleTime: 60 * 1000, + }); + + const navigation = useNavigation(); + useEffect(() => { + navigation.setOptions({ + title: library?.Name || "", + }); + }, [library]); + + const fetchItems = useCallback( + async ({ + pageParam, + }: { + pageParam: number; + }): Promise => { + if (!api || !library) return null; + + console.log("[libraryId] ~", library); + + let itemType: BaseItemKind | undefined; + + // This fix makes sure to only return 1 type of items, if defined. + // This is because the underlying directory some times contains other types, and we don't want to show them. + if (library.CollectionType === "movies") { + itemType = "Movie"; + } else if (library.CollectionType === "tvshows") { + itemType = "Series"; + } else if (library.CollectionType === "boxsets") { + itemType = "BoxSet"; + } + + const response = await getItemsApi(api).getItems({ + userId: user?.Id, + parentId: libraryId, + limit: 36, + startIndex: pageParam, + sortBy: [sortBy[0], "SortName", "ProductionYear"], + sortOrder: [sortOrder[0]], + enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"], + // true is needed for merged versions + recursive: true, + imageTypeLimit: 1, + fields: ["PrimaryImageAspectRatio", "SortName"], + genres: selectedGenres, + tags: selectedTags, + years: selectedYears.map((year) => parseInt(year)), + includeItemTypes: itemType ? [itemType] : undefined, + }); + + return response.data || null; + }, + [ + api, + user?.Id, + libraryId, + library, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ] + ); + + const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = + useInfiniteQuery({ + queryKey: [ + "library-items", + libraryId, + selectedGenres, + selectedYears, + selectedTags, + sortBy, + sortOrder, + ], + queryFn: fetchItems, + getNextPageParam: (lastPage, pages) => { + if ( + !lastPage?.Items || + !lastPage?.TotalRecordCount || + lastPage?.TotalRecordCount === 0 + ) + return undefined; + + const totalItems = lastPage.TotalRecordCount; + const accumulatedItems = pages.reduce( + (acc, curr) => acc + (curr?.Items?.length || 0), + 0 + ); + + if (accumulatedItems < totalItems) { + return lastPage?.Items?.length * pages.length; + } else { + return undefined; + } + }, + initialPageParam: 0, + enabled: !!api && !!user?.Id && !!library, + }); + + const flatData = useMemo(() => { + return ( + (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) || + [] + ); + }, [data]); + + const renderItem = useCallback( + ({ item, index }: { item: BaseItemDto; index: number }) => ( + + + {/* */} + + + + + ), + [orientation] + ); + + const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []); + + const ListHeaderComponent = useCallback( + () => ( + + , + }, + { + key: "genre", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Genres || []; + }} + set={setSelectedGenres} + values={selectedGenres} + title="Genres" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "year", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Years || []; + }} + set={setSelectedYears} + values={selectedYears} + title="Years" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => item.includes(search)} + /> + ), + }, + { + key: "tags", + component: ( + { + if (!api) return null; + const response = await getFilterApi( + api + ).getQueryFiltersLegacy({ + userId: user?.Id, + parentId: libraryId, + }); + return response.data.Tags || []; + }} + set={setSelectedTags} + values={selectedTags} + title="Tags" + renderItemLabel={(item) => item.toString()} + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortBy", + component: ( + sortOptions.map((s) => s.key)} + set={setSortBy} + values={sortBy} + title="Sort By" + renderItemLabel={(item) => + sortOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + { + key: "sortOrder", + component: ( + sortOrderOptions.map((s) => s.key)} + set={setSortOrder} + values={sortOrder} + title="Sort Order" + renderItemLabel={(item) => + sortOrderOptions.find((i) => i.key === item)?.value || "" + } + searchFilter={(item, search) => + item.toLowerCase().includes(search.toLowerCase()) + } + /> + ), + }, + ]} + renderItem={({ item }) => item.component} + keyExtractor={(item) => item.key} + /> + + ), + [ + libraryId, + api, + user?.Id, + selectedGenres, + setSelectedGenres, + selectedYears, + setSelectedYears, + selectedTags, + setSelectedTags, + sortBy, + setSortBy, + sortOrder, + setSortOrder, + isFetching, + ] + ); + + const insets = useSafeAreaInsets(); + + if (isLoading || isLibraryLoading) + return ( + + + + ); + + if (flatData.length === 0) + return ( + + No items found + + ); + + return ( + + No results + + } + contentInsetAdjustmentBehavior="automatic" + data={flatData} + renderItem={renderItem} + extraData={[orientation, nrOfCols]} + keyExtractor={keyExtractor} + estimatedItemSize={244} + numColumns={nrOfCols} + onEndReached={() => { + if (hasNextPage) { + fetchNextPage(); + } + }} + onEndReachedThreshold={1} + ListHeaderComponent={ListHeaderComponent} + contentContainerStyle={{ + paddingBottom: 24, + paddingLeft: insets.left, + paddingRight: insets.right, + }} + ItemSeparatorComponent={() => ( + + )} + /> + ); +}; + +export default React.memo(Page); diff --git a/app/(auth)/(tabs)/(libraries)/_layout.tsx b/app/(auth)/(tabs)/(libraries)/_layout.tsx new file mode 100644 index 00000000..489a20e5 --- /dev/null +++ b/app/(auth)/(tabs)/(libraries)/_layout.tsx @@ -0,0 +1,210 @@ +import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; +import { useSettings } from "@/utils/atoms/settings"; +import { Ionicons } from "@expo/vector-icons"; +import { Stack } from "expo-router"; +import { Platform } from "react-native"; +import * as DropdownMenu from "zeego/dropdown-menu"; + +export default function IndexLayout() { + const [settings, updateSettings] = useSettings(); + + if (!settings?.libraryOptions) return null; + + return ( + + ( + + + + + + Display + + + + Display + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "row", + }, + }) + } + > + + + Row + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + display: "list", + }, + }) + } + > + + + List + + + + + + + Image style + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "poster", + }, + }) + } + > + + + Poster + + + + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + imageStyle: "cover", + }, + }) + } + > + + + Cover + + + + + + + { + if (settings.libraryOptions.imageStyle === "poster") + return; + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showTitles: newValue === "on" ? true : false, + }, + }); + }} + > + + + Show titles + + + { + updateSettings({ + libraryOptions: { + ...settings.libraryOptions, + showStats: newValue === "on" ? true : false, + }, + }); + }} + > + + + Show stats + + + + + + + + ), + }} + /> + + {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( + + ))} + + + ); +} diff --git a/app/(auth)/(tabs)/(libraries)/index.tsx b/app/(auth)/(tabs)/(libraries)/index.tsx new file mode 100644 index 00000000..ef729254 --- /dev/null +++ b/app/(auth)/(tabs)/(libraries)/index.tsx @@ -0,0 +1,102 @@ +import { Text } from "@/components/common/Text"; +import { LibraryItemCard } from "@/components/library/LibraryItemCard"; +import { Loader } from "@/components/Loader"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { + getUserLibraryApi, + getUserViewsApi, +} from "@jellyfin/sdk/lib/utils/api"; +import { FlashList } from "@shopify/flash-list"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAtom } from "jotai"; +import { useEffect } from "react"; +import { StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +export default function index() { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + const queryClient = useQueryClient(); + const [settings] = useSettings(); + + const { data, isLoading: isLoading } = useQuery({ + queryKey: ["user-views", user?.Id], + queryFn: async () => { + if (!api || !user?.Id) { + return null; + } + + const response = await getUserViewsApi(api).getUserViews({ + userId: user.Id, + }); + + return response.data.Items || null; + }, + enabled: !!api && !!user?.Id, + staleTime: 60 * 1000 * 60, + }); + + useEffect(() => { + for (const item of data || []) { + queryClient.prefetchQuery({ + queryKey: ["library", item.Id], + queryFn: async () => { + if (!item.Id || !user?.Id || !api) return null; + const response = await getUserLibraryApi(api).getItem({ + itemId: item.Id, + userId: user?.Id, + }); + return response.data; + }, + staleTime: 60 * 1000, + }); + } + }, [data]); + + const insets = useSafeAreaInsets(); + + if (isLoading) + return ( + + + + ); + + if (!data) + return ( + + No libraries found + + ); + + return ( + } + keyExtractor={(item) => item.Id || ""} + ItemSeparatorComponent={() => + settings?.libraryOptions?.display === "row" ? ( + + ) : ( + + ) + } + estimatedItemSize={200} + /> + ); +} diff --git a/app/(auth)/(tabs)/library/_layout.tsx b/app/(auth)/(tabs)/(search)/_layout.tsx similarity index 59% rename from app/(auth)/(tabs)/library/_layout.tsx rename to app/(auth)/(tabs)/(search)/_layout.tsx index 505bbe66..2917f1da 100644 --- a/app/(auth)/(tabs)/library/_layout.tsx +++ b/app/(auth)/(tabs)/(search)/_layout.tsx @@ -1,7 +1,8 @@ -import { Stack, useRouter } from "expo-router"; +import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack"; +import { Stack } from "expo-router"; import { Platform } from "react-native"; -export default function IndexLayout() { +export default function SearchLayout() { return ( + {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( + + ))} + ); } diff --git a/app/(auth)/(tabs)/(search)/index.tsx b/app/(auth)/(tabs)/(search)/index.tsx new file mode 100644 index 00000000..aaa5b1ab --- /dev/null +++ b/app/(auth)/(tabs)/(search)/index.tsx @@ -0,0 +1,554 @@ +import { Input } from "@/components/common/Input"; +import { Text } from "@/components/common/Text"; +import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; +import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; +import { ItemCardText } from "@/components/ItemCardText"; +import { Loader } from "@/components/Loader"; +import AlbumCover from "@/components/posters/AlbumCover"; +import MoviePoster from "@/components/posters/MoviePoster"; +import SeriesPoster from "@/components/posters/SeriesPoster"; +import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; +import { + BaseItemDto, + BaseItemKind, +} from "@jellyfin/sdk/lib/generated-client/models"; +import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api"; +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { Href, router, useLocalSearchParams, useNavigation } from "expo-router"; +import { useAtom } from "jotai"; +import React, { + PropsWithChildren, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; +import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useDebounce } from "use-debounce"; +import {useJellyseerr} from "@/hooks/useJellyseerr"; +import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search"; +import {MediaType} from "@/utils/jellyseerr/server/constants/media"; +import JellyseerrPoster from "@/components/posters/JellyseerrPoster"; +import {Tag} from "@/components/GenreTags"; +import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide"; +import {sortBy} from "lodash"; + +type SearchType = 'Library' | 'Discover'; + +const exampleSearches = [ + "Lord of the rings", + "Avengers", + "Game of Thrones", + "Breaking Bad", + "Stranger Things", + "The Mandalorian", +]; + +export default function search() { + const params = useLocalSearchParams(); + const insets = useSafeAreaInsets(); + + const { q, prev } = params as { q: string; prev: Href }; + + const [searchType, setSearchType] = useState("Library"); + const [search, setSearch] = useState(""); + + const [debouncedSearch] = useDebounce(search, 500); + + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const [settings] = useSettings(); + const { jellyseerrApi } = useJellyseerr(); + + const searchEngine = useMemo(() => { + return settings?.searchEngine || "Jellyfin"; + }, [settings]); + + useEffect(() => { + if (q && q.length > 0) setSearch(q); + }, [q]); + + const searchFn = useCallback( + async ({ + types, + query, + }: { + types: BaseItemKind[]; + query: string; + }): Promise => { + if (!api || !query) return []; + + try { + if (searchEngine === "Jellyfin") { + const searchApi = await getSearchApi(api).getSearchHints({ + searchTerm: query, + limit: 10, + includeItemTypes: types, + }); + + return (searchApi.data.SearchHints as BaseItemDto[]) || []; + } else { + if (!settings?.marlinServerUrl) return []; + const url = `${ + settings.marlinServerUrl + }/search?q=${encodeURIComponent(query)}&includeItemTypes=${types + .map((type) => encodeURIComponent(type)) + .join("&includeItemTypes=")}`; + + const response1 = await axios.get(url); + const ids = response1.data.ids; + + if (!ids || !ids.length) return []; + + const response2 = await getItemsApi(api).getItems({ + ids, + enableImageTypes: ["Primary", "Backdrop", "Thumb"], + }); + + return (response2.data.Items as BaseItemDto[]) || []; + } + } catch (error) { + console.error("Error during search:", error); + return []; // Ensure an empty array is returned in case of an error + } + }, + [api, searchEngine, settings] + ); + + const navigation = useNavigation(); + useLayoutEffect(() => { + if (Platform.OS === "ios") + navigation.setOptions({ + headerSearchBarOptions: { + placeholder: "Search...", + onChangeText: (e: any) => { + router.setParams({ q: "" }); + setSearch(e.nativeEvent.text); + }, + hideWhenScrolling: false, + autoFocus: true, + }, + }); + }, [navigation]); + + const { data: movies, isFetching: l1 } = useQuery({ + queryKey: ["search", "movies", debouncedSearch], + queryFn: () => + searchFn({ + query: debouncedSearch, + types: ["Movie"], + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: jellyseerrResults, isFetching: j1 } = useQuery({ + queryKey: ["search", "jellyseerrResults", debouncedSearch], + queryFn: async () => { + const response = await jellyseerrApi?.search({ + query: new URLSearchParams(debouncedSearch).toString(), + page: 1, // todo: maybe rework page & page-size if first results are not enough... + language: 'en' + }) + + return response?.results; + }, + enabled: !!jellyseerrApi && searchType === "Discover" && debouncedSearch.length > 0, + }); + + const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({ + queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch], + queryFn: async () => jellyseerrApi?.discoverSettings(), + enabled: !!jellyseerrApi && searchType === "Discover" && debouncedSearch.length == 0, + }); + + const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(() => + jellyseerrResults?.filter(r => r.mediaType === MediaType.MOVIE) as MovieResult[], + [jellyseerrResults] + ) + + const jellyseerrTvResults: TvResult[] | undefined = useMemo(() => + jellyseerrResults?.filter(r => r.mediaType === MediaType.TV) as TvResult[], + [jellyseerrResults] + ) + + const { data: series, isFetching: l2 } = useQuery({ + queryKey: ["search", "series", debouncedSearch], + queryFn: () => + searchFn({ + query: debouncedSearch, + types: ["Series"], + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: episodes, isFetching: l3 } = useQuery({ + queryKey: ["search", "episodes", debouncedSearch], + queryFn: () => + searchFn({ + query: debouncedSearch, + types: ["Episode"], + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: collections, isFetching: l7 } = useQuery({ + queryKey: ["search", "collections", debouncedSearch], + queryFn: () => + searchFn({ + query: debouncedSearch, + types: ["BoxSet"], + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: actors, isFetching: l8 } = useQuery({ + queryKey: ["search", "actors", debouncedSearch], + queryFn: () => + searchFn({ + query: debouncedSearch, + types: ["Person"], + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: artists, isFetching: l4 } = useQuery({ + queryKey: ["search", "artists", debouncedSearch], + queryFn: () => + searchFn({ + query: debouncedSearch, + types: ["MusicArtist"], + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: albums, isFetching: l5 } = useQuery({ + queryKey: ["search", "albums", debouncedSearch], + queryFn: () => + searchFn({ + query: debouncedSearch, + types: ["MusicAlbum"], + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const { data: songs, isFetching: l6 } = useQuery({ + queryKey: ["search", "songs", debouncedSearch], + queryFn: () => + searchFn({ + query: debouncedSearch, + types: ["Audio"], + }), + enabled: searchType === "Library" && debouncedSearch.length > 0, + }); + + const noResults = useMemo(() => { + return !( + artists?.length || + albums?.length || + songs?.length || + movies?.length || + episodes?.length || + series?.length || + collections?.length || + actors?.length || + jellyseerrMovieResults?.length || + jellyseerrTvResults?.length + ); + }, [artists, episodes, albums, songs, movies, series, collections, actors, jellyseerrResults]); + + const loading = useMemo(() => { + return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2; + }, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]); + + return ( + <> + + + {Platform.OS === "android" && ( + + setSearch(text)} + /> + + )} + {jellyseerrApi && ( + + setSearchType('Library')}> + + + setSearchType('Discover')}> + + + + )} + {!!q && ( + + + Results for {q} + + + )} + {searchType === "Library" && ( + <> + m.Id!)} + renderItem={(item: BaseItemDto) => ( + + + + {item.Name} + + + {item.ProductionYear} + + + )} + /> + m.Id!)} + header="Series" + renderItem={(item: BaseItemDto) => ( + + + + {item.Name} + + + {item.ProductionYear} + + + )} + /> + m.Id!)} + header="Episodes" + renderItem={(item: BaseItemDto) => ( + + + + + )} + /> + m.Id!)} + header="Collections" + renderItem={(item: BaseItemDto) => ( + + + + {item.Name} + + + )} + /> + m.Id!)} + header="Actors" + renderItem={(item: BaseItemDto) => ( + + + + + )} + /> + m.Id!)} + header="Artists" + renderItem={(item: BaseItemDto) => ( + + + + + )} + /> + m.Id!)} + header="Albums" + renderItem={(item: BaseItemDto) => ( + + + + + )} + /> + m.Id!)} + header="Songs" + renderItem={(item: BaseItemDto) => ( + + + + + )} + /> + + )} + {searchType === "Discover" && ( + <> + ( + + )} + /> + ( + + )} + /> + + )} + + {loading ? ( + + + + ) : noResults && debouncedSearch.length > 0 ? ( + + + No results found for + + + "{debouncedSearch}" + + + ) : debouncedSearch.length === 0 && searchType === 'Library' ? ( + + {exampleSearches.map((e) => ( + setSearch(e)} + key={e} + className="mb-2" + > + {e} + + ))} + + ) : debouncedSearch.length === 0 && searchType === 'Discover' ? ( + + {sortBy?.(jellyseerrDiscoverSettings?.filter(s => s.enabled), 'order') + .map((slide) => ) + } + + ) : null} + + + + ); +} + +type Props = { + ids?: string[] | null; + items?: T[]; + renderItem: (item: any) => React.ReactNode; + header?: string; +}; + +const SearchItemWrapper = ({ ids, items, renderItem, header }: PropsWithChildren>) => { + const [api] = useAtom(apiAtom); + const [user] = useAtom(userAtom); + + const { data, isLoading: l1 } = useQuery({ + queryKey: ["items", ids], + queryFn: async () => { + if (!user?.Id || !api || !ids || ids.length === 0) { + return []; + } + + const itemPromises = ids.map((id) => + getUserItemData({ + api, + userId: user.Id, + itemId: id, + }) + ); + + const results = await Promise.all(itemPromises); + + // Filter out null items + return results.filter( + (item) => item !== null + ) as unknown as BaseItemDto[]; + }, + enabled: !!ids && ids.length > 0 && !!api && !!user?.Id, + staleTime: Infinity, + }); + + if (!data && (!items || items.length === 0)) return null; + + return ( + <> + {header} + + { + data && data?.length > 0 + ? data.map((item) => renderItem(item)) + : + items && items?.length > 0 + ? items.map(i => renderItem(i)) + : undefined + } + + + ); +}; diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 8b0472b3..d56de5da 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -1,90 +1,93 @@ -import { TabBarIcon } from "@/components/navigation/TabBarIcon"; -import { Colors } from "@/constants/Colors"; -import { BlurView } from "expo-blur"; -import * as NavigationBar from "expo-navigation-bar"; -import { Tabs } from "expo-router"; -import React, { useEffect } from "react"; +import React from "react"; +import { Platform } from "react-native"; import { useTranslation } from "react-i18next"; -import { Platform, StyleSheet } from "react-native"; + +import { withLayoutContext } from "expo-router"; + +import { + createNativeBottomTabNavigator, + NativeBottomTabNavigationEventMap, +} from "@bottom-tabs/react-navigation"; + +const { Navigator } = createNativeBottomTabNavigator(); + +import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs"; + +import { Colors } from "@/constants/Colors"; +import type { + ParamListBase, + TabNavigationState, +} from "@react-navigation/native"; +import { SystemBars } from "react-native-edge-to-edge"; +import { useSettings } from "@/utils/atoms/settings"; + +export const NativeTabs = withLayoutContext< + BottomTabNavigationOptions, + typeof Navigator, + TabNavigationState, + NativeBottomTabNavigationEventMap +>(Navigator); export default function TabLayout() { + const [settings] = useSettings(); const { t } = useTranslation(); - - useEffect(() => { - if (Platform.OS === "android") { - NavigationBar.setBackgroundColorAsync("#121212"); - NavigationBar.setBorderColorAsync("#121212"); - } - }, []); - return ( - - Platform.OS === "ios" ? ( - - ) : undefined, - }} - > - - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - + <> +