## 🌟 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
-
-
-
-
-
-
-
-
+
+
+
+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.
-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
-
-
-
## 📝 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
+
+[](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
+
+ )}
+
+
+ (
+
+ )}
+ >
+
+
+ Delete all Movies
+ Delete all TV-Series
+ Delete all
+
+
+
+ >
+ );
+}
+
+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")}
+
+
+ router.push("/(auth)/downloads")}
+ justify="center"
+ iconRight={
+
+ }
+ >
+ {t("home.goToDownloads")}
+
+ {
+ checkConnection();
+ }}
+ justify="center"
+ className="mt-2"
+ iconRight={
+ loadingRetry ? null : (
+
+ )
+ }
+ >
+ {loadingRetry ? (
+
+ ) : (
+ "Retry"
+ )}
+
+
+
+ );
+ }
+
+ 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 (
+
+
+ {/* {
+ registerBackgroundFetchAsync();
+ }}
+ >
+ registerBackgroundFetchAsync
+ */}
+
+ User Info
+
+
+
+
+
+
+
+ Log out
+
+
+
+
+ Quick connect
+
+ Authorize
+
+
+
+
+
+
+ Storage
+
+ {size && App usage: {size.app.bytesToReadable()} }
+
+ {size && (
+
+ Available: {size.remaining?.bytesToReadable()}, Total:{" "}
+ {size.total?.bytesToReadable()}
+
+ )}
+
+
+ Delete all downloaded files
+
+
+ Delete all logs
+
+
+
+ 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 ?
+ Request
+ :
+ bottomSheetModalRef?.current?.present()}
+ iconLeft={
+
+ }
+ style={{
+ borderWidth: 1,
+ borderStyle: "solid",
+ }}>
+ Report issue
+
+ }
+
+
+ {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}
+
+ ))}
+
+
+
+
+
+
+
+ Submit
+
+
+
+
+
+ );
+}
+
+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,
- }}
- >
-
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
- (
-
- ),
- }}
- />
-
+ <>
+
+
+
+
+ require("@/assets/icons/house.fill.png")
+ : () => ({ sfSymbol: "house" }),
+ }}
+ />
+
+ require("@/assets/icons/magnifyingglass.png")
+ : () => ({ sfSymbol: "magnifyingglass" }),
+ }}
+ />
+
+ require("@/assets/icons/server.rack.png")
+ : () => ({ sfSymbol: "rectangle.stack" }),
+ }}
+ />
+ require("@/assets/icons/list.png")
+ : () => ({ sfSymbol: "list.dash" }),
+ }}
+ />
+
+ >
);
}
diff --git a/app/(auth)/(tabs)/home/_layout.tsx b/app/(auth)/(tabs)/home/_layout.tsx
deleted file mode 100644
index 374e9157..00000000
--- a/app/(auth)/(tabs)/home/_layout.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Chromecast } from "@/components/Chromecast";
-import { Feather } from "@expo/vector-icons";
-import { Stack, useRouter } from "expo-router";
-import { useTranslation } from "react-i18next";
-import { Platform, View } from "react-native";
-import { TouchableOpacity } from "react-native";
-
-export default function IndexLayout() {
- const router = useRouter();
- const { t } = useTranslation();
- return (
-
- (
- {
- router.push("/(auth)/downloads");
- }}
- >
-
-
- ),
- headerRight: () => (
-
-
- {
- router.push("/(auth)/settings");
- }}
- >
-
-
-
-
-
- ),
- }}
- />
-
- );
-}
diff --git a/app/(auth)/(tabs)/home/index.tsx b/app/(auth)/(tabs)/home/index.tsx
deleted file mode 100644
index 10018ed0..00000000
--- a/app/(auth)/(tabs)/home/index.tsx
+++ /dev/null
@@ -1,307 +0,0 @@
-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 { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { Ionicons } from "@expo/vector-icons";
-import { BaseItemDto } 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 { useQuery, useQueryClient } from "@tanstack/react-query";
-import { useRouter } from "expo-router";
-import { useAtom } from "jotai";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { useTranslation } from "react-i18next";
-import { RefreshControl, ScrollView, View } from "react-native";
-
-export default function index() {
- const router = useRouter();
- const queryClient = useQueryClient();
-
- const { i18n, t } = useTranslation();
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const [loading, setLoading] = useState(false);
- const [settings, _] = useSettings();
-
- const [isConnected, setIsConnected] = useState(null);
-
- 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);
- });
-
- return () => {
- unsubscribe();
- };
- }, []);
-
- const { data, isLoading, isError } = useQuery({
- queryKey: ["resumeItems", user?.Id],
- queryFn: async () =>
- (api &&
- (
- await getItemsApi(api).getResumeItems({
- userId: user?.Id,
- })
- ).data.Items) ||
- [],
- enabled: !!api && !!user?.Id,
- staleTime: 60 * 1000,
- });
-
- const { data: _nextUpData, isLoading: isLoadingNextUp } = useQuery({
- queryKey: ["nextUp-all", user?.Id],
- queryFn: async () =>
- (api &&
- (
- await getTvShowsApi(api).getNextUp({
- userId: user?.Id,
- fields: ["MediaSourceCount"],
- })
- ).data.Items) ||
- [],
- enabled: !!api && !!user?.Id,
- staleTime: 0,
- });
-
- const nextUpData = useMemo(() => {
- return _nextUpData?.filter((i) => !data?.find((d) => d.Id === i.Id));
- }, [_nextUpData]);
-
- const { data: collections } = useQuery({
- queryKey: ["collectinos", 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 movieCollectionId = useMemo(() => {
- return collections?.find((c) => c.CollectionType === "movies")?.Id;
- }, [collections]);
-
- const tvShowCollectionId = useMemo(() => {
- return collections?.find((c) => c.CollectionType === "tvshows")?.Id;
- }, [collections]);
-
- const {
- data: recentlyAddedInMovies,
- isLoading: isLoadingRecentlyAddedMovies,
- } = useQuery({
- queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
- queryFn: async () =>
- (api &&
- (
- await getUserLibraryApi(api).getLatestMedia({
- userId: user?.Id,
- limit: 50,
- fields: ["PrimaryImageAspectRatio", "Path"],
- imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
- parentId: movieCollectionId,
- })
- ).data) ||
- [],
- enabled: !!api && !!user?.Id && !!movieCollectionId,
- staleTime: 60 * 1000,
- });
-
- const {
- data: recentlyAddedInTVShows,
- isLoading: isLoadingRecentlyAddedTVShows,
- } = useQuery({
- queryKey: ["recentlyAddedInTVShows", user?.Id, tvShowCollectionId],
- queryFn: async () =>
- (api &&
- (
- await getUserLibraryApi(api).getLatestMedia({
- userId: user?.Id,
- limit: 50,
- fields: ["PrimaryImageAspectRatio", "Path"],
- imageTypeLimit: 1,
- enableImageTypes: ["Primary", "Backdrop", "Thumb"],
- parentId: tvShowCollectionId,
- })
- ).data) ||
- [],
- enabled: !!api && !!user?.Id && !!tvShowCollectionId,
- staleTime: 60 * 1000,
- });
-
- const { data: suggestions, isLoading: isLoadingSuggestions } = useQuery<
- BaseItemDto[]
- >({
- queryKey: ["suggestions", user?.Id],
- queryFn: async () =>
- (api &&
- (
- await getSuggestionsApi(api).getSuggestions({
- userId: user?.Id,
- limit: 5,
- mediaType: ["Video"],
- })
- ).data.Items) ||
- [],
- enabled: !!api && !!user?.Id,
- staleTime: 60 * 1000,
- });
-
- const { data: mediaListCollections } = useQuery({
- queryKey: [
- "mediaListCollections-home",
- user?.Id,
- settings?.mediaListCollectionIds,
- ],
- queryFn: async () => {
- if (!api || !user?.Id) return [];
-
- const response = await getItemsApi(api).getItems({
- userId: user.Id,
- tags: ["medialist", "promoted"],
- recursive: true,
- fields: ["Tags"],
- includeItemTypes: ["BoxSet"],
- });
-
- const ids =
- response.data.Items?.filter(
- (c) =>
- c.Name !== "cf_carousel" &&
- settings?.mediaListCollectionIds?.includes(c.Id!)
- ) ?? [];
-
- return ids;
- },
- enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
- staleTime: 0,
- });
-
- const refetch = useCallback(async () => {
- setLoading(true);
- await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
- await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
- await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
- await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
- await queryClient.refetchQueries({ queryKey: ["suggestions"] });
- await queryClient.refetchQueries({
- queryKey: ["mediaListCollections-home"],
- });
- setLoading(false);
- }, [queryClient, user?.Id]);
-
- if (isConnected === false) {
- return (
-
- {t("home.noInternet")}
-
- {t("home.noInternetMessage")}
-
-
- router.push("/(auth)/downloads")}
- justify="center"
- iconRight={
-
- }
- >
- {t("home.goToDownloads")}
-
-
-
- );
- }
-
- if (isError)
- return (
-
- {t("home.oops")}
- {t("home.errorMessage")}
-
- );
-
- if (isLoading)
- return (
-
-
-
- );
-
- return (
-
- }
- >
-
-
-
-
-
-
-
- {mediaListCollections?.map((ml) => (
-
- ))}
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/(auth)/(tabs)/library/collections/[collectionId].tsx b/app/(auth)/(tabs)/library/collections/[collectionId].tsx
deleted file mode 100644
index 629ccf8e..00000000
--- a/app/(auth)/(tabs)/library/collections/[collectionId].tsx
+++ /dev/null
@@ -1,335 +0,0 @@
-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 MoviePoster from "@/components/posters/MoviePoster";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import {
- genreFilterAtom,
- sortByAtom,
- sortOptions,
- sortOrderAtom,
- sortOrderOptions,
- tagsFilterAtom,
- yearFilterAtom,
-} from "@/utils/atoms/filters";
-import {
- BaseItemDtoQueryResult,
- BaseItemKind,
-} from "@jellyfin/sdk/lib/generated-client/models";
-import { getFilterApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
-import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import React, { useCallback, useEffect, useMemo } from "react";
-import { NativeScrollEvent, ScrollView, View } from "react-native";
-
-const isCloseToBottom = ({
- layoutMeasurement,
- contentOffset,
- contentSize,
-}: NativeScrollEvent) => {
- const paddingToBottom = 200;
- return (
- layoutMeasurement.height + contentOffset.y >=
- contentSize.height - paddingToBottom
- );
-};
-
-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 [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 getItemsApi(api).getItems({
- userId: user?.Id,
- ids: [collectionId],
- });
- const data = response.data.Items?.[0];
- return data;
- },
- enabled: !!api && !!user?.Id && !!collectionId,
- staleTime: 0,
- });
-
- const fetchItems = useCallback(
- async ({
- pageParam,
- }: {
- pageParam: number;
- }): Promise => {
- if (!api || !collection) return null;
-
- const includeItemTypes: BaseItemKind[] = [];
-
- switch (collection?.CollectionType) {
- case "movies":
- includeItemTypes.push("Movie");
- break;
- case "boxsets":
- includeItemTypes.push("BoxSet");
- break;
- case "tvshows":
- includeItemTypes.push("Series");
- break;
- case "music":
- includeItemTypes.push("MusicAlbum");
- break;
- default:
- break;
- }
-
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- parentId: collectionId,
- limit: 66,
- startIndex: pageParam,
- sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
- sortOrder: [sortOrder[0].key],
- includeItemTypes,
- enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
- recursive: true,
- imageTypeLimit: 1,
- fields: ["PrimaryImageAspectRatio", "SortName"],
- genres: selectedGenres,
- tags: selectedTags,
- years: selectedYears.map((year) => parseInt(year)),
- });
-
- return response.data || null;
- },
- [
- api,
- user?.Id,
- collectionId,
- collection?.CollectionType,
- selectedGenres,
- selectedYears,
- selectedTags,
- sortBy,
- sortOrder,
- ]
- );
-
- const { data, isFetching, fetchNextPage } = useInfiniteQuery({
- queryKey: [
- "library-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 type = useMemo(() => {
- return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
- }, [data]);
-
- const flatData = useMemo(() => {
- return data?.pages.flatMap((p) => p?.Items) || [];
- }, [data]);
-
- if (!collection || !collection.CollectionType) return null;
-
- return (
- {
- if (isCloseToBottom(nativeEvent)) {
- fetchNextPage();
- }
- }}
- scrollEventThrottle={400}
- >
-
-
-
-
-
- {
- if (!api) return null;
- const response = await getFilterApi(
- api
- ).getQueryFiltersLegacy({
- userId: user?.Id,
- includeItemTypes: type ? [type] : [],
- parentId: collectionId,
- });
- return response.data.Genres || [];
- }}
- set={setSelectedGenres}
- values={selectedGenres}
- title="Genres"
- renderItemLabel={(item) => item.toString()}
- searchFilter={(item, search) =>
- item.toLowerCase().includes(search.toLowerCase())
- }
- />
- {
- if (!api) return null;
- const response = await getFilterApi(
- api
- ).getQueryFiltersLegacy({
- userId: user?.Id,
- includeItemTypes: type ? [type] : [],
- parentId: collectionId,
- });
- return response.data.Tags || [];
- }}
- set={setSelectedTags}
- values={selectedTags}
- title="Tags"
- renderItemLabel={(item) => item.toString()}
- searchFilter={(item, search) =>
- item.toLowerCase().includes(search.toLowerCase())
- }
- />
- {
- if (!api) return null;
- const response = await getFilterApi(
- api
- ).getQueryFiltersLegacy({
- userId: user?.Id,
- includeItemTypes: type ? [type] : [],
- parentId: collectionId,
- });
- return (
- response.data.Years?.sort((a, b) => b - a).map((y) =>
- y.toString()
- ) || []
- );
- }}
- set={setSelectedYears}
- values={selectedYears}
- title="Years"
- renderItemLabel={(item) => item.toString()}
- searchFilter={(item, search) =>
- item.toLowerCase().includes(search.toLowerCase())
- }
- />
- {
- return sortOptions;
- }}
- set={setSortBy}
- values={sortBy}
- title="Sort by"
- renderItemLabel={(item) => item.value}
- searchFilter={(item, search) =>
- item.value.toLowerCase().includes(search.toLowerCase()) ||
- item.value.toLowerCase().includes(search.toLowerCase())
- }
- showSearch={false}
- />
- {
- return sortOrderOptions;
- }}
- set={setSortOrder}
- values={sortOrder}
- title="Order by"
- renderItemLabel={(item) => item.value}
- searchFilter={(item, search) =>
- item.value.toLowerCase().includes(search.toLowerCase()) ||
- item.value.toLowerCase().includes(search.toLowerCase())
- }
- />
-
-
- {!type && isFetching && (
-
- )}
-
-
- {flatData.map(
- (item, index) =>
- item && (
-
-
-
-
- )
- )}
- {flatData.length % 3 !== 0 && (
-
- )}
-
-
-
- );
-};
-
-export default page;
diff --git a/app/(auth)/(tabs)/library/index.tsx b/app/(auth)/(tabs)/library/index.tsx
deleted file mode 100644
index 56295670..00000000
--- a/app/(auth)/(tabs)/library/index.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { Loader } from "@/components/Loader";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
-import { FlashList } from "@shopify/flash-list";
-import { useQuery } from "@tanstack/react-query";
-import { Image } from "expo-image";
-import { useRouter } from "expo-router";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { TouchableOpacity, View } from "react-native";
-
-export default function index() {
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- 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,
- });
-
- if (isLoading)
- return (
-
-
-
- );
-
- return (
- }
- keyExtractor={(item) => item.Id || ""}
- ItemSeparatorComponent={() => }
- estimatedItemSize={200}
- />
- );
-}
-
-interface Props {
- collection: BaseItemDto;
-}
-
-const CollectionCard: React.FC = ({ collection }) => {
- const router = useRouter();
-
- const [api] = useAtom(apiAtom);
-
- const url = useMemo(
- () =>
- getPrimaryImageUrl({
- api,
- item: collection,
- }),
- [collection]
- );
-
- if (!url) return null;
-
- return (
- {
- router.push(`/library/collections/${collection.Id}`);
- }}
- >
-
-
-
- {collection.Name}
-
-
-
- );
-};
diff --git a/app/(auth)/(tabs)/search/index.tsx b/app/(auth)/(tabs)/search/index.tsx
deleted file mode 100644
index 3465da81..00000000
--- a/app/(auth)/(tabs)/search/index.tsx
+++ /dev/null
@@ -1,376 +0,0 @@
-import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
-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 { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { router, useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import React, { useLayoutEffect, useMemo, useState } from "react";
-import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
-import { useDebounce } from "use-debounce";
-
-const exampleSearches = [
- "Lord of the rings",
- "Avengers",
- "Game of Thrones",
- "Breaking Bad",
- "Stranger Things",
- "The Mandalorian",
-];
-
-export default function search() {
- const [search, setSearch] = useState("");
-
- const [debouncedSearch] = useDebounce(search, 500);
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const navigation = useNavigation();
- useLayoutEffect(() => {
- if (Platform.OS === "ios")
- navigation.setOptions({
- headerSearchBarOptions: {
- placeholder: "Search...",
- onChangeText: (e: any) => setSearch(e.nativeEvent.text),
- hideWhenScrolling: false,
- autoFocus: true,
- },
- });
- }, [navigation]);
-
- const { data: movies, isLoading: l1 } = useQuery({
- queryKey: ["search-movies", debouncedSearch],
- queryFn: async () => {
- if (!api || !user || debouncedSearch.length === 0) return [];
-
- const searchApi = await getSearchApi(api).getSearchHints({
- searchTerm: debouncedSearch,
- limit: 10,
- includeItemTypes: ["Movie"],
- });
-
- return searchApi.data.SearchHints;
- },
- });
-
- const { data: series, isLoading: l2 } = useQuery({
- queryKey: ["search-series", debouncedSearch],
- queryFn: async () => {
- if (!api || !user || debouncedSearch.length === 0) return [];
-
- const searchApi = await getSearchApi(api).getSearchHints({
- searchTerm: debouncedSearch,
- limit: 10,
- includeItemTypes: ["Series"],
- });
-
- return searchApi.data.SearchHints;
- },
- });
-
- const { data: episodes, isLoading: l3 } = useQuery({
- queryKey: ["search-episodes", debouncedSearch],
- queryFn: async () => {
- if (!api || !user || debouncedSearch.length === 0) return [];
-
- const searchApi = await getSearchApi(api).getSearchHints({
- searchTerm: debouncedSearch,
- limit: 10,
- includeItemTypes: ["Episode"],
- });
-
- return searchApi.data.SearchHints;
- },
- });
-
- const { data: artists, isLoading: l4 } = useQuery({
- queryKey: ["search-artists", debouncedSearch],
- queryFn: async () => {
- if (!api || !user || debouncedSearch.length === 0) return [];
-
- const searchApi = await getSearchApi(api).getSearchHints({
- searchTerm: debouncedSearch,
- limit: 10,
- includeItemTypes: ["MusicArtist"],
- });
-
- return searchApi.data.SearchHints;
- },
- });
-
- const { data: albums, isLoading: l5 } = useQuery({
- queryKey: ["search-albums", debouncedSearch],
- queryFn: async () => {
- if (!api || !user || debouncedSearch.length === 0) return [];
-
- const searchApi = await getSearchApi(api).getSearchHints({
- searchTerm: debouncedSearch,
- limit: 10,
- includeItemTypes: ["MusicAlbum"],
- });
-
- return searchApi.data.SearchHints;
- },
- });
-
- const { data: songs, isLoading: l6 } = useQuery({
- queryKey: ["search-songs", debouncedSearch],
- queryFn: async () => {
- if (!api || !user || debouncedSearch.length === 0) return [];
-
- const searchApi = await getSearchApi(api).getSearchHints({
- searchTerm: debouncedSearch,
- limit: 10,
- includeItemTypes: ["Audio"],
- });
-
- return searchApi.data.SearchHints;
- },
- });
-
- const noResults = useMemo(() => {
- return !(
- artists?.length ||
- albums?.length ||
- songs?.length ||
- movies?.length ||
- episodes?.length ||
- series?.length
- );
- }, [artists, episodes, albums, songs, movies, series]);
-
- const loading = useMemo(() => {
- return l1 || l2 || l3 || l4 || l5 || l6;
- }, [l1, l2, l3, l4, l5, l6]);
-
- return (
- <>
-
-
- {Platform.OS === "android" && (
-
- setSearch(text)}
- />
-
- )}
- m.Id!)}
- renderItem={(data) => (
-
- data={data}
- renderItem={(item) => (
- router.push(`/items/${item.Id}`)}
- >
-
-
- {item.Name}
-
-
- {item.ProductionYear}
-
-
- )}
- />
- )}
- />
- m.Id!)}
- header="Series"
- renderItem={(data) => (
-
- data={data}
- renderItem={(item) => (
- router.push(`/series/${item.Id}`)}
- className="flex flex-col w-32"
- >
-
-
- {item.Name}
-
-
- {item.ProductionYear}
-
-
- )}
- />
- )}
- />
- m.Id!)}
- header="Episodes"
- renderItem={(data) => (
-
- data={data}
- renderItem={(item) => (
- router.push(`/items/${item.Id}`)}
- className="flex flex-col w-48"
- >
-
-
-
- )}
- />
- )}
- />
- m.Id!)}
- header="Artists"
- renderItem={(data) => (
-
- data={data}
- renderItem={(item) => (
-
-
-
-
- )}
- />
- )}
- />
- m.Id!)}
- header="Albums"
- renderItem={(data) => (
-
- data={data}
- renderItem={(item) => (
-
-
-
-
- )}
- />
- )}
- />
- m.Id!)}
- header="Songs"
- renderItem={(data) => (
-
- data={data}
- renderItem={(item) => (
-
-
-
-
- )}
- />
- )}
- />
- {loading ? (
-
-
-
- ) : noResults && debouncedSearch.length > 0 ? (
-
-
- No results found for
-
-
- "{debouncedSearch}"
-
-
- ) : debouncedSearch.length === 0 ? (
-
- {exampleSearches.map((e) => (
- setSearch(e)}
- key={e}
- className="mb-2"
- >
- {e}
-
- ))}
-
- ) : null}
-
-
- >
- );
-}
-
-type Props = {
- ids?: string[] | null;
- renderItem: (data: BaseItemDto[]) => React.ReactNode;
- header?: string;
-};
-
-const SearchItemWrapper: React.FC = ({ ids, renderItem, header }) => {
- 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) return null;
-
- return (
- <>
- {header}
- {renderItem(data)}
- >
- );
-};
diff --git a/app/(auth)/collections/[collectionId].tsx b/app/(auth)/collections/[collectionId].tsx
deleted file mode 100644
index 00601193..00000000
--- a/app/(auth)/collections/[collectionId].tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { Loader } from "@/components/Loader";
-import ArtistPoster from "@/components/posters/ArtistPoster";
-import MoviePoster from "@/components/posters/MoviePoster";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { Ionicons } from "@expo/vector-icons";
-import {
- BaseItemDto,
- BaseItemKind,
- ItemSortBy,
-} from "@jellyfin/sdk/lib/generated-client/models";
-import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { router, useLocalSearchParams } from "expo-router";
-import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
-import { ScrollView, TouchableOpacity, 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 { data: collection } = useQuery({
- queryKey: ["collection", collectionId],
- queryFn: async () => {
- if (!api) return null;
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- ids: [collectionId],
- });
- const data = response.data.Items?.[0];
- return data;
- },
- enabled: !!api && !!user?.Id && !!collectionId,
- staleTime: 0,
- });
-
- const [startIndex, setStartIndex] = useState(0);
-
- const { data, isLoading, isError } = useQuery<{
- Items: BaseItemDto[];
- TotalRecordCount: number;
- }>({
- queryKey: ["collection-items", collection?.Id, startIndex],
- queryFn: async () => {
- if (!api || !collectionId)
- return {
- Items: [],
- TotalRecordCount: 0,
- };
-
- const sortBy: ItemSortBy[] = [];
- const includeItemTypes: BaseItemKind[] = [];
-
- switch (collection?.CollectionType) {
- case "movies":
- sortBy.push("SortName", "ProductionYear");
- break;
- case "boxsets":
- sortBy.push("IsFolder", "SortName");
- break;
- default:
- sortBy.push("SortName");
- break;
- }
-
- switch (collection?.CollectionType) {
- case "movies":
- includeItemTypes.push("Movie");
- break;
- case "boxsets":
- includeItemTypes.push("BoxSet");
- break;
- case "tvshows":
- includeItemTypes.push("Series");
- break;
- case "music":
- includeItemTypes.push("MusicAlbum");
- break;
- default:
- break;
- }
-
- const response = await getItemsApi(api).getItems({
- userId: user?.Id,
- parentId: collectionId,
- limit: 100,
- startIndex,
- sortBy,
- sortOrder: ["Ascending"],
- includeItemTypes,
- enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
- recursive: true,
- imageTypeLimit: 1,
- fields: ["PrimaryImageAspectRatio", "SortName"],
- });
-
- const data = response.data.Items;
-
- return {
- Items: data || [],
- TotalRecordCount: response.data.TotalRecordCount || 0,
- };
- },
- enabled: !!collection?.Id && !!api && !!user?.Id,
- });
-
- const totalItems = useMemo(() => {
- return data?.TotalRecordCount;
- }, [data]);
-
- return (
-
-
-
- {collection?.Name}
-
-
- {startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
- {totalItems}
-
-
- {
- setStartIndex((prev) => Math.max(prev - 100, 0));
- }}
- >
-
-
- {
- setStartIndex((prev) => prev + 100);
- }}
- >
-
-
-
-
-
- {isLoading ? (
-
-
-
- ) : (
-
- {data?.Items?.map((item: BaseItemDto, index: number) => (
- {
- if (item?.Type === "Series") {
- router.push(`/series/${item.Id}`);
- } else if (item.IsFolder) {
- router.push(`/collections/${item?.Id}`);
- } else {
- router.push(`/items/${item.Id}`);
- }
- }}
- >
-
- {collection?.CollectionType === "movies" ? (
-
- ) : collection?.CollectionType === "music" ? (
-
- ) : (
-
- )}
- {item.Name}
-
- {item.ProductionYear}
-
-
-
- ))}
-
- )}
-
- {!isLoading && (
-
- {
- setStartIndex((prev) => Math.max(prev - 100, 0));
- }}
- >
-
-
- {
- setStartIndex((prev) => prev + 100);
- }}
- >
-
-
-
- )}
-
- );
-};
-
-export default page;
diff --git a/app/(auth)/downloads.tsx b/app/(auth)/downloads.tsx
deleted file mode 100644
index 73603158..00000000
--- a/app/(auth)/downloads.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-import { Text } from "@/components/common/Text";
-import { MovieCard } from "@/components/downloads/MovieCard";
-import { SeriesCard } from "@/components/downloads/SeriesCard";
-import { Loader } from "@/components/Loader";
-import { runningProcesses } from "@/utils/atoms/downloads";
-import { queueAtom } from "@/utils/atoms/queue";
-import { Ionicons } from "@expo/vector-icons";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useQuery } from "@tanstack/react-query";
-import { router } from "expo-router";
-import { FFmpegKit } from "ffmpeg-kit-react-native";
-import { useAtom } from "jotai";
-import { useMemo } from "react";
-import { ScrollView, TouchableOpacity, View } from "react-native";
-
-const downloads: React.FC = () => {
- const [process, setProcess] = useAtom(runningProcesses);
- const [queue, setQueue] = useAtom(queueAtom);
-
- const { data: downloadedFiles, isLoading } = useQuery({
- queryKey: ["downloaded_files", process?.item.Id],
- queryFn: async () =>
- JSON.parse(
- (await AsyncStorage.getItem("downloaded_files")) || "[]"
- ) as BaseItemDto[],
- staleTime: 0,
- });
-
- const movies = useMemo(
- () => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
- [downloadedFiles]
- );
-
- const groupedBySeries = useMemo(() => {
- const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
- const series: { [key: string]: BaseItemDto[] } = {};
- episodes?.forEach((e) => {
- if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
- series[e.SeriesName!].push(e);
- });
- return Object.values(series);
- }, [downloadedFiles]);
-
- const eta = useMemo(() => {
- const length = process?.item?.RunTimeTicks || 0;
-
- if (!process?.speed || !process?.progress) return "";
-
- const timeLeft =
- (length - length * (process.progress / 100)) / process.speed;
-
- return formatNumber(timeLeft / 10000);
- }, [process]);
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
- Queue
-
- {queue.map((q) => (
- router.push(`/(auth)/items/${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"
- >
-
- {q.item.Name}
- {q.item.Type}
-
- {
- setQueue((prev) => prev.filter((i) => i.id !== q.id));
- }}
- >
-
-
-
- ))}
-
-
- {queue.length === 0 && (
- No items in queue
- )}
-
-
-
- Active download
- {process?.item ? (
- router.push(`/(auth)/items/${process.item.Id}`)}
- className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
- >
-
- {process.item.Name}
-
- {process.item.Type}
-
-
-
- {process.progress.toFixed(0)}%
-
-
- {process.speed?.toFixed(2)}x
-
-
- ETA {eta}
-
-
-
- {
- FFmpegKit.cancel();
- setProcess(null);
- }}
- >
-
-
-
-
- ) : (
- No active downloads
- )}
-
-
- {movies.length > 0 && (
-
-
- Movies
-
- {movies?.length}
-
-
- {movies?.map((item: BaseItemDto) => (
-
-
-
- ))}
-
- )}
- {groupedBySeries?.map((items: BaseItemDto[], index: number) => (
-
- ))}
-
-
- );
-};
-
-export default downloads;
-
-/*
- * Format a number (Date.getTime) to a human readable string ex. 2m 34s
- * @param {number} num - The number to format
- *
- * @returns {string} - The formatted string
- */
-const formatNumber = (num: number) => {
- const minutes = Math.floor(num / 60000);
- const seconds = ((num % 60000) / 1000).toFixed(0);
- return `${minutes}m ${seconds}s`;
-};
diff --git a/app/(auth)/items/[id].tsx b/app/(auth)/items/[id].tsx
deleted file mode 100644
index 91cbc7cf..00000000
--- a/app/(auth)/items/[id].tsx
+++ /dev/null
@@ -1,302 +0,0 @@
-import { AudioTrackSelector } from "@/components/AudioTrackSelector";
-import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
-import {
- currentlyPlayingItemAtom,
- fullScreenAtom,
- playingAtom,
-} from "@/components/CurrentlyPlayingBar";
-import { DownloadItem } from "@/components/DownloadItem";
-import { Loader } from "@/components/Loader";
-import { OverviewText } from "@/components/OverviewText";
-import { PlayButton } from "@/components/PlayButton";
-import { PlayedStatus } from "@/components/PlayedStatus";
-import { Ratings } from "@/components/Ratings";
-import { SimilarItems } from "@/components/SimilarItems";
-import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
-import { Text } from "@/components/common/Text";
-import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
-import { CastAndCrew } from "@/components/series/CastAndCrew";
-import { CurrentSeries } from "@/components/series/CurrentSeries";
-import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
-import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useSettings } from "@/utils/atoms/settings";
-import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
-import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
-import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
-import { chromecastProfile } from "@/utils/profiles/chromecast";
-import ios from "@/utils/profiles/ios";
-import native from "@/utils/profiles/native";
-import old from "@/utils/profiles/old";
-import { getMediaInfoApi } 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, useState } from "react";
-import { View } from "react-native";
-import CastContext, {
- PlayServicesState,
- useCastDevice,
- useRemoteMediaClient,
-} from "react-native-google-cast";
-import { ParallaxScrollView } from "../../../components/ParallaxPage";
-
-const page: React.FC = () => {
- const local = useLocalSearchParams();
- const { id } = local as { id: string };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const [settings] = useSettings();
-
- const castDevice = useCastDevice();
-
- const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
- const [, setPlaying] = useAtom(playingAtom);
- const [, setFullscreen] = useAtom(fullScreenAtom);
-
- const client = useRemoteMediaClient();
- const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
- const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
- const [selectedSubtitleStream, setSelectedSubtitleStream] =
- useState(0);
- const [maxBitrate, setMaxBitrate] = useState({
- key: "Max",
- value: undefined,
- });
-
- const { data: item, isLoading: l1 } = useQuery({
- queryKey: ["item", id],
- queryFn: async () =>
- await getUserItemData({
- api,
- userId: user?.Id,
- itemId: id,
- }),
- enabled: !!id && !!api,
- staleTime: 60,
- });
-
- const { data: sessionData } = useQuery({
- queryKey: ["sessionData", item?.Id],
- queryFn: async () => {
- if (!api || !user?.Id || !item?.Id) return null;
- const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
- itemId: item?.Id,
- userId: user?.Id,
- });
-
- return playbackData.data;
- },
- enabled: !!item?.Id && !!api && !!user?.Id,
- staleTime: 0,
- });
-
- const { data: playbackUrl } = useQuery({
- queryKey: [
- "playbackUrl",
- item?.Id,
- maxBitrate,
- castDevice,
- selectedAudioStream,
- selectedSubtitleStream,
- settings,
- ],
- queryFn: async () => {
- if (!api || !user?.Id || !sessionData) return null;
-
- let deviceProfile: any = ios;
-
- if (castDevice?.deviceId) {
- deviceProfile = chromecastProfile;
- } else if (settings?.deviceProfile === "Native") {
- deviceProfile = native;
- } else if (settings?.deviceProfile === "Old") {
- deviceProfile = old;
- }
-
- const url = await getStreamUrl({
- api,
- userId: user.Id,
- item,
- startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
- maxStreamingBitrate: maxBitrate.value,
- sessionData,
- deviceProfile,
- audioStreamIndex: selectedAudioStream,
- subtitleStreamIndex: selectedSubtitleStream,
- forceDirectPlay: settings?.forceDirectPlay,
- });
-
- console.log("Transcode URL: ", url);
-
- return url;
- },
- enabled: !!sessionData && !!api && !!user?.Id && !!item?.Id,
- staleTime: 0,
- });
-
- const onPressPlay = useCallback(
- async (type: "device" | "cast" = "device") => {
- if (!playbackUrl || !item) return;
-
- if (type === "cast" && client) {
- await CastContext.getPlayServicesState().then((state) => {
- if (state && state !== PlayServicesState.SUCCESS)
- CastContext.showPlayServicesErrorDialog(state);
- else {
- client.loadMedia({
- mediaInfo: {
- contentUrl: playbackUrl,
- contentType: "video/mp4",
- metadata: {
- type: item.Type === "Episode" ? "tvShow" : "movie",
- title: item.Name || "",
- subtitle: item.Overview || "",
- },
- },
- startTime: 0,
- });
- }
- });
- } else {
- setCurrentlyPlying({
- item,
- playbackUrl,
- });
- setPlaying(true);
- if (settings?.openFullScreenVideoPlayerByDefault === true) {
- setFullscreen(true);
- }
- }
- },
- [playbackUrl, item, settings]
- );
-
- const backdropUrl = useMemo(
- () =>
- getBackdropUrl({
- api,
- item,
- quality: 90,
- width: 1000,
- }),
- [item]
- );
-
- const logoUrl = useMemo(
- () => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
- [item]
- );
-
- if (l1)
- return (
-
-
-
- );
-
- if (!item?.Id || !backdropUrl) return null;
-
- return (
-
- }
- logo={
- <>
- {logoUrl ? (
-
- ) : null}
- >
- }
- >
-
-
- {item.Type === "Episode" ? (
-
- ) : (
- <>
-
- >
- )}
- {item?.ProductionYear}
-
-
-
-
- {playbackUrl ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
- setMaxBitrate(val)}
- selected={maxBitrate}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- {item.Type === "Episode" && (
-
-
-
- )}
-
-
-
-
-
- );
-};
-
-export default page;
diff --git a/app/(auth)/player/_layout.tsx b/app/(auth)/player/_layout.tsx
new file mode 100644
index 00000000..96d08058
--- /dev/null
+++ b/app/(auth)/player/_layout.tsx
@@ -0,0 +1,40 @@
+import { Stack } from "expo-router";
+import React from "react";
+import { SystemBars } from "react-native-edge-to-edge";
+
+export default function Layout() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx
new file mode 100644
index 00000000..5e62d868
--- /dev/null
+++ b/app/(auth)/player/direct-player.tsx
@@ -0,0 +1,534 @@
+import { BITRATES } from "@/components/BitrateSelector";
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { Controls } from "@/components/video-player/controls/Controls";
+import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useOrientationSettings } from "@/hooks/useOrientationSettings";
+import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
+import { useWebSocket } from "@/hooks/useWebsockets";
+import { VlcPlayerView } from "@/modules/vlc-player";
+import {
+ PlaybackStatePayload,
+ ProgressUpdatePayload,
+ VlcPlayerViewRef,
+} from "@/modules/vlc-player/src/VlcPlayer.types";
+import { useDownload } from "@/providers/DownloadProvider";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import { writeToLog } from "@/utils/log";
+import native from "@/utils/profiles/native";
+import { msToTicks, ticksToSeconds } from "@/utils/time";
+import { Api } from "@jellyfin/sdk";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import {
+ getPlaystateApi,
+ getUserLibraryApi,
+} from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import * as Haptics from "expo-haptics";
+import { useFocusEffect, useGlobalSearchParams } from "expo-router";
+import { useAtomValue } from "jotai";
+import React, {
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+ useEffect,
+} from "react";
+import {
+ Alert,
+ BackHandler,
+ View,
+ AppState,
+ AppStateStatus,
+ Platform,
+} from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+import settings from "../(tabs)/(home)/settings";
+import { useSettings } from "@/utils/atoms/settings";
+
+export default function page() {
+ const videoRef = useRef(null);
+ const user = useAtomValue(userAtom);
+ const api = useAtomValue(apiAtom);
+
+ const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
+ const [showControls, _setShowControls] = useState(true);
+ const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isBuffering, setIsBuffering] = useState(true);
+ const [isVideoLoaded, setIsVideoLoaded] = useState(false);
+
+ const progress = useSharedValue(0);
+ const isSeeking = useSharedValue(false);
+ const cacheProgress = useSharedValue(0);
+
+ const { getDownloadedItem } = useDownload();
+ const revalidateProgressCache = useInvalidatePlaybackProgressCache();
+
+ const setShowControls = useCallback((show: boolean) => {
+ _setShowControls(show);
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }, []);
+
+ const {
+ itemId,
+ audioIndex: audioIndexStr,
+ subtitleIndex: subtitleIndexStr,
+ mediaSourceId,
+ bitrateValue: bitrateValueStr,
+ offline: offlineStr,
+ } = useGlobalSearchParams<{
+ itemId: string;
+ audioIndex: string;
+ subtitleIndex: string;
+ mediaSourceId: string;
+ bitrateValue: string;
+ offline: string;
+ }>();
+ const [settings] = useSettings();
+ const offline = offlineStr === "true";
+
+ const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
+ const subtitleIndex = subtitleIndexStr ? parseInt(subtitleIndexStr, 10) : -1;
+ const bitrateValue = bitrateValueStr
+ ? parseInt(bitrateValueStr, 10)
+ : BITRATES[0].value;
+
+ const {
+ data: item,
+ isLoading: isLoadingItem,
+ isError: isErrorItem,
+ } = useQuery({
+ queryKey: ["item", itemId],
+ queryFn: async () => {
+ console.log("Offline:", offline);
+ if (offline) {
+ const item = await getDownloadedItem(itemId);
+ if (item) return item.item;
+ }
+
+ const res = await getUserLibraryApi(api!).getItem({
+ itemId,
+ userId: user?.Id,
+ });
+
+ return res.data;
+ },
+ enabled: !!itemId,
+ staleTime: 0,
+ });
+
+ const {
+ data: stream,
+ isLoading: isLoadingStreamUrl,
+ isError: isErrorStreamUrl,
+ } = useQuery({
+ queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
+ queryFn: async () => {
+ console.log("Offline:", offline);
+ if (offline) {
+ const data = await getDownloadedItem(itemId);
+ if (!data?.mediaSource) return null;
+
+ const url = await getDownloadedFileUrl(data.item.Id!);
+
+ if (item)
+ return {
+ mediaSource: data.mediaSource,
+ url,
+ sessionId: undefined,
+ };
+ }
+
+ const res = await getStreamUrl({
+ api,
+ item,
+ startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
+ userId: user?.Id,
+ audioStreamIndex: audioIndex,
+ maxStreamingBitrate: bitrateValue,
+ mediaSourceId: mediaSourceId,
+ subtitleStreamIndex: subtitleIndex,
+ deviceProfile: native,
+ });
+
+ if (!res) return null;
+
+ const { mediaSource, sessionId, url } = res;
+
+ if (!sessionId || !mediaSource || !url) {
+ Alert.alert("Error", "Failed to get stream url");
+ return null;
+ }
+
+ return {
+ mediaSource,
+ sessionId,
+ url,
+ };
+ },
+ enabled: !!itemId && !!item,
+ staleTime: 0,
+ });
+
+ const togglePlay = useCallback(async () => {
+ if (!api) return;
+
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (isPlaying) {
+ await videoRef.current?.pause();
+
+ if (!offline && stream) {
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: msToTicks(progress.value),
+ isPaused: true,
+ playMethod: stream.url?.includes("m3u8")
+ ? "Transcode"
+ : "DirectStream",
+ playSessionId: stream.sessionId,
+ });
+ }
+
+ console.log("Actually marked as paused");
+ } else {
+ videoRef.current?.play();
+ if (!offline && stream) {
+ await getPlaystateApi(api).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: msToTicks(progress.value),
+ isPaused: false,
+ playMethod: stream?.url.includes("m3u8")
+ ? "Transcode"
+ : "DirectStream",
+ playSessionId: stream.sessionId,
+ });
+ }
+ }
+ }, [
+ isPlaying,
+ api,
+ item,
+ stream,
+ videoRef,
+ audioIndex,
+ subtitleIndex,
+ mediaSourceId,
+ offline,
+ progress.value,
+ ]);
+
+ const reportPlaybackStopped = useCallback(async () => {
+ if (offline) return;
+
+ const currentTimeInTicks = msToTicks(progress.value);
+
+ await getPlaystateApi(api!).onPlaybackStopped({
+ itemId: item?.Id!,
+ mediaSourceId: mediaSourceId,
+ positionTicks: currentTimeInTicks,
+ playSessionId: stream?.sessionId!,
+ });
+
+ revalidateProgressCache();
+ }, [api, item, mediaSourceId, stream]);
+
+ const stop = useCallback(() => {
+ reportPlaybackStopped();
+ setIsPlaybackStopped(true);
+ videoRef.current?.stop();
+ }, [videoRef, reportPlaybackStopped]);
+
+ // TODO: unused should remove.
+ const reportPlaybackStart = useCallback(async () => {
+ if (offline) return;
+
+ if (!stream) return;
+ await getPlaystateApi(api!).onPlaybackStart({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
+ });
+ }, [api, item, mediaSourceId, stream]);
+
+ const onProgress = useCallback(
+ async (data: ProgressUpdatePayload) => {
+ if (isSeeking.value === true) return;
+ if (isPlaybackStopped === true) return;
+
+ const { currentTime } = data.nativeEvent;
+
+ if (isBuffering) {
+ setIsBuffering(false);
+ }
+
+ progress.value = currentTime;
+
+ if (offline) return;
+
+ const currentTimeInTicks = msToTicks(currentTime);
+
+ if (!item?.Id || !stream) return;
+
+ console.log(
+ "onProgress ~",
+ currentTimeInTicks,
+ isPlaying,
+ `AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
+ );
+
+ await getPlaystateApi(api!).onPlaybackProgress({
+ itemId: item.Id,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.floor(currentTimeInTicks),
+ isPaused: !isPlaying,
+ playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: stream.sessionId,
+ });
+ },
+ [item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
+ );
+
+ useOrientation();
+ useOrientationSettings();
+
+ useWebSocket({
+ isPlaying: isPlaying,
+ togglePlay: togglePlay,
+ stopPlayback: stop,
+ offline,
+ });
+
+ const onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
+ const { state, isBuffering, isPlaying } = e.nativeEvent;
+
+ if (state === "Playing") {
+ setIsPlaying(true);
+ return;
+ }
+
+ if (state === "Paused") {
+ setIsPlaying(false);
+ return;
+ }
+
+ if (isPlaying) {
+ setIsPlaying(true);
+ setIsBuffering(false);
+ } else if (isBuffering) {
+ setIsBuffering(true);
+ }
+ }, []);
+
+ const startPosition = useMemo(() => {
+ if (offline) return 0;
+
+ return item?.UserData?.PlaybackPositionTicks
+ ? ticksToSeconds(item.UserData.PlaybackPositionTicks)
+ : 0;
+ }, [item]);
+
+ useFocusEffect(
+ React.useCallback(() => {
+ return async () => {
+ stop();
+ console.log("Unmounted");
+ };
+ }, [])
+ );
+
+ const [appState, setAppState] = useState(AppState.currentState);
+
+ useEffect(() => {
+ const handleAppStateChange = (nextAppState: AppStateStatus) => {
+ if (appState.match(/inactive|background/) && nextAppState === "active") {
+ console.log("App has come to the foreground!");
+ // Handle app coming to the foreground
+ } else if (nextAppState.match(/inactive|background/)) {
+ console.log("App has gone to the background!");
+ // Handle app going to the background
+ if (videoRef.current && videoRef.current.pause) {
+ videoRef.current.pause();
+ }
+ }
+ setAppState(nextAppState);
+ };
+
+ // Use AppState.addEventListener and return a cleanup function
+ const subscription = AppState.addEventListener(
+ "change",
+ handleAppStateChange
+ );
+
+ return () => {
+ // Cleanup the event listener when the component is unmounted
+ subscription.remove();
+ };
+ }, [appState]);
+
+ // Preselection of audio and subtitle tracks.
+
+ if (!settings) return null;
+
+ let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
+ let externalTrack = { name: "", DeliveryUrl: "" };
+
+ const allSubs =
+ stream?.mediaSource.MediaStreams?.filter(
+ (sub) => sub.Type === "Subtitle"
+ ) || [];
+ const chosenSubtitleTrack = allSubs.find(
+ (sub) => sub.Index === subtitleIndex
+ );
+ const allAudio =
+ stream?.mediaSource.MediaStreams?.filter(
+ (audio) => audio.Type === "Audio"
+ ) || [];
+ const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
+
+ // Direct playback CASE
+ if (!bitrateValue) {
+ // If Subtitle is embedded we can use the position to select it straight away.
+ if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
+ initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
+ } else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
+ // If Subtitle is external we need to pass the URL to the player.
+ externalTrack = {
+ name: chosenSubtitleTrack.DisplayTitle || "",
+ DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
+ };
+ }
+
+ if (chosenAudioTrack)
+ initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
+ } else {
+ // Transcoded playback CASE
+ if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
+ externalTrack = {
+ name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
+ DeliveryUrl: "",
+ };
+ }
+ }
+
+ if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
+ return (
+
+
+
+ );
+
+ if (isErrorItem || isErrorStreamUrl)
+ return (
+
+ Error
+
+ );
+
+ return (
+
+
+ {}}
+ onVideoLoadEnd={() => {
+ setIsVideoLoaded(true);
+ }}
+ onVideoError={(e) => {
+ console.error("Video Error:", e.nativeEvent);
+ Alert.alert(
+ "Error",
+ "An error occurred while playing the video. Check logs in settings."
+ );
+ writeToLog("ERROR", "Video Error", e.nativeEvent);
+ }}
+ />
+
+ {videoRef.current && (
+
+ )}
+
+ );
+}
+
+export function usePoster(
+ item: BaseItemDto,
+ api: Api | null
+): string | undefined {
+ const poster = useMemo(() => {
+ if (!item || !api) return undefined;
+ return item.Type === "Audio"
+ ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
+ : getBackdropUrl({
+ api,
+ item: item,
+ quality: 70,
+ width: 200,
+ });
+ }, [item, api]);
+
+ return poster ?? undefined;
+}
diff --git a/app/(auth)/player/music-player.tsx b/app/(auth)/player/music-player.tsx
new file mode 100644
index 00000000..13aa4ecc
--- /dev/null
+++ b/app/(auth)/player/music-player.tsx
@@ -0,0 +1,420 @@
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { Controls } from "@/components/video-player/controls/Controls";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useOrientationSettings } from "@/hooks/useOrientationSettings";
+import { useWebSocket } from "@/hooks/useWebsockets";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import { secondsToTicks } from "@/utils/secondsToTicks";
+import { Api } from "@jellyfin/sdk";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import {
+ getPlaystateApi,
+ getUserLibraryApi,
+} from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import * as Haptics from "expo-haptics";
+import { Image } from "expo-image";
+import { useFocusEffect, useLocalSearchParams } from "expo-router";
+import { useAtomValue } from "jotai";
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { Pressable, useWindowDimensions, View } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+import Video, { OnProgressData, VideoRef } from "react-native-video";
+
+export default function page() {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const [settings] = useSettings();
+ const videoRef = useRef(null);
+ const windowDimensions = useWindowDimensions();
+
+ const firstTime = useRef(true);
+
+ const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
+ const [showControls, setShowControls] = useState(true);
+ const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isBuffering, setIsBuffering] = useState(true);
+
+ const progress = useSharedValue(0);
+ const isSeeking = useSharedValue(false);
+ const cacheProgress = useSharedValue(0);
+
+ const {
+ itemId,
+ audioIndex: audioIndexStr,
+ subtitleIndex: subtitleIndexStr,
+ mediaSourceId,
+ bitrateValue: bitrateValueStr,
+ } = useLocalSearchParams<{
+ itemId: string;
+ audioIndex: string;
+ subtitleIndex: string;
+ mediaSourceId: string;
+ bitrateValue: string;
+ }>();
+
+ const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
+ const subtitleIndex = subtitleIndexStr
+ ? parseInt(subtitleIndexStr, 10)
+ : undefined;
+ const bitrateValue = bitrateValueStr
+ ? parseInt(bitrateValueStr, 10)
+ : undefined;
+
+ const {
+ data: item,
+ isLoading: isLoadingItem,
+ isError: isErrorItem,
+ } = useQuery({
+ queryKey: ["item", itemId],
+ queryFn: async () => {
+ if (!api) return;
+ const res = await getUserLibraryApi(api).getItem({
+ itemId,
+ userId: user?.Id,
+ });
+
+ return res.data;
+ },
+ enabled: !!itemId && !!api,
+ staleTime: 0,
+ });
+
+ const {
+ data: stream,
+ isLoading: isLoadingStreamUrl,
+ isError: isErrorStreamUrl,
+ } = useQuery({
+ queryKey: ["stream-url"],
+ queryFn: async () => {
+ if (!api) return;
+ const res = await getStreamUrl({
+ api,
+ item,
+ startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
+ userId: user?.Id,
+ audioStreamIndex: audioIndex,
+ maxStreamingBitrate: bitrateValue,
+ mediaSourceId: mediaSourceId,
+ subtitleStreamIndex: subtitleIndex,
+ });
+
+ if (!res) return null;
+
+ const { mediaSource, sessionId, url } = res;
+
+ if (!sessionId || !mediaSource || !url) return null;
+
+ return {
+ mediaSource,
+ sessionId,
+ url,
+ };
+ },
+ });
+
+ const poster = usePoster(item, api);
+ const videoSource = useVideoSource(item, api, poster, stream?.url);
+
+ const togglePlay = useCallback(
+ async (ticks: number) => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (isPlaying) {
+ videoRef.current?.pause();
+ await getPlaystateApi(api!).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.floor(ticks),
+ isPaused: true,
+ playMethod: stream?.url.includes("m3u8")
+ ? "Transcode"
+ : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ } else {
+ videoRef.current?.resume();
+ await getPlaystateApi(api!).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.floor(ticks),
+ isPaused: false,
+ playMethod: stream?.url.includes("m3u8")
+ ? "Transcode"
+ : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ }
+ },
+ [
+ isPlaying,
+ api,
+ item,
+ videoRef,
+ settings,
+ audioIndex,
+ subtitleIndex,
+ mediaSourceId,
+ stream,
+ ]
+ );
+
+ const play = useCallback(() => {
+ console.log("play");
+ videoRef.current?.resume();
+ reportPlaybackStart();
+ }, [videoRef]);
+
+ const pause = useCallback(() => {
+ console.log("play");
+ videoRef.current?.pause();
+ }, [videoRef]);
+
+ const stop = useCallback(() => {
+ console.log("stop");
+ setIsPlaybackStopped(true);
+ videoRef.current?.pause();
+ reportPlaybackStopped();
+ }, [videoRef]);
+
+ const seek = useCallback(
+ (seconds: number) => {
+ videoRef.current?.seek(seconds);
+ },
+ [videoRef]
+ );
+
+ const reportPlaybackStopped = async () => {
+ if (!item?.Id) return;
+ await getPlaystateApi(api!).onPlaybackStopped({
+ itemId: item.Id,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.floor(progress.value),
+ playSessionId: stream?.sessionId,
+ });
+ };
+
+ const reportPlaybackStart = async () => {
+ if (!item?.Id) return;
+ await getPlaystateApi(api!).onPlaybackStart({
+ itemId: item?.Id,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ };
+
+ const onProgress = useCallback(
+ async (data: OnProgressData) => {
+ if (isSeeking.value === true) return;
+ if (isPlaybackStopped === true) return;
+
+ const ticks = data.currentTime * 10000000;
+
+ progress.value = secondsToTicks(data.currentTime);
+ cacheProgress.value = secondsToTicks(data.playableDuration);
+ setIsBuffering(data.playableDuration === 0);
+
+ if (!item?.Id || data.currentTime === 0) return;
+
+ await getPlaystateApi(api!).onPlaybackProgress({
+ itemId: item.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.round(ticks),
+ isPaused: !isPlaying,
+ playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ },
+ [
+ item,
+ isPlaying,
+ api,
+ isPlaybackStopped,
+ audioIndex,
+ subtitleIndex,
+ mediaSourceId,
+ stream,
+ ]
+ );
+
+ useFocusEffect(
+ useCallback(() => {
+ play();
+
+ return () => {
+ stop();
+ };
+ }, [play, stop])
+ );
+
+ useOrientation();
+ useOrientationSettings();
+
+ useWebSocket({
+ isPlaying: isPlaying,
+ pauseVideo: pause,
+ playVideo: play,
+ stopPlayback: stop,
+ });
+
+ if (isLoadingItem || isLoadingStreamUrl)
+ return (
+
+
+
+ );
+
+ if (isErrorItem || isErrorStreamUrl)
+ return (
+
+ Error
+
+ );
+
+ if (!item || !stream)
+ return (
+
+ Error
+
+ );
+
+ return (
+
+
+
+
+
+ {
+ setShowControls(!showControls);
+ }}
+ className="absolute z-0 h-full w-full opacity-0"
+ >
+ {videoSource && (
+ {}}
+ onLoad={() => {
+ if (firstTime.current === true) {
+ play();
+ firstTime.current = false;
+ }
+ }}
+ progressUpdateInterval={500}
+ playWhenInactive={true}
+ allowsExternalPlayback={true}
+ playInBackground={true}
+ pictureInPicture={true}
+ showNotificationControls={true}
+ ignoreSilentSwitch="ignore"
+ fullscreen={false}
+ onPlaybackStateChanged={(state) => {
+ setIsPlaying(state.isPlaying);
+ }}
+ />
+ )}
+
+
+
+
+ );
+}
+
+export function usePoster(
+ item: BaseItemDto | null | undefined,
+ api: Api | null
+): string | undefined {
+ const poster = useMemo(() => {
+ if (!item || !api) return undefined;
+ return item.Type === "Audio"
+ ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
+ : getBackdropUrl({
+ api,
+ item: item,
+ quality: 70,
+ width: 200,
+ });
+ }, [item, api]);
+
+ return poster ?? undefined;
+}
+
+export function useVideoSource(
+ item: BaseItemDto | null | undefined,
+ api: Api | null,
+ poster: string | undefined,
+ url?: string | null
+) {
+ const videoSource = useMemo(() => {
+ if (!item || !api || !url) {
+ return null;
+ }
+
+ const startPosition = item?.UserData?.PlaybackPositionTicks
+ ? Math.round(item.UserData.PlaybackPositionTicks / 10000)
+ : 0;
+
+ return {
+ uri: url,
+ isNetwork: true,
+ startPosition,
+ headers: getAuthHeaders(api),
+ metadata: {
+ artist: item?.AlbumArtist ?? undefined,
+ title: item?.Name || "Unknown",
+ description: item?.Overview ?? undefined,
+ imageUri: poster,
+ subtitle: item?.Album ?? undefined,
+ },
+ };
+ }, [item, api, poster]);
+
+ return videoSource;
+}
diff --git a/app/(auth)/player/transcoding-player.tsx b/app/(auth)/player/transcoding-player.tsx
new file mode 100644
index 00000000..f382411b
--- /dev/null
+++ b/app/(auth)/player/transcoding-player.tsx
@@ -0,0 +1,560 @@
+import { Text } from "@/components/common/Text";
+import { Loader } from "@/components/Loader";
+import { Controls } from "@/components/video-player/controls/Controls";
+import { useOrientation } from "@/hooks/useOrientation";
+import { useOrientationSettings } from "@/hooks/useOrientationSettings";
+import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
+import { useWebSocket } from "@/hooks/useWebsockets";
+import { TrackInfo } from "@/modules/vlc-player";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
+import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import transcoding from "@/utils/profiles/transcoding";
+import { secondsToTicks } from "@/utils/secondsToTicks";
+import { Api } from "@jellyfin/sdk";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import {
+ getPlaystateApi,
+ getUserLibraryApi,
+} from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import * as Haptics from "expo-haptics";
+import { useFocusEffect, useLocalSearchParams } from "expo-router";
+import { useAtomValue } from "jotai";
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { View } from "react-native";
+import { useSharedValue } from "react-native-reanimated";
+import Video, {
+ OnProgressData,
+ SelectedTrack,
+ SelectedTrackType,
+ VideoRef,
+} from "react-native-video";
+import { SubtitleHelper } from "@/utils/SubtitleHelper";
+
+const Player = () => {
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+ const [settings] = useSettings();
+ const videoRef = useRef(null);
+
+ const firstTime = useRef(true);
+ const revalidateProgressCache = useInvalidatePlaybackProgressCache();
+
+ const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
+ const [showControls, _setShowControls] = useState(true);
+ const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [isBuffering, setIsBuffering] = useState(true);
+ const [isVideoLoaded, setIsVideoLoaded] = useState(false);
+
+ const setShowControls = useCallback((show: boolean) => {
+ _setShowControls(show);
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }, []);
+
+ const progress = useSharedValue(0);
+ const isSeeking = useSharedValue(false);
+ const cacheProgress = useSharedValue(0);
+
+ const {
+ itemId,
+ audioIndex: audioIndexStr,
+ subtitleIndex: subtitleIndexStr,
+ mediaSourceId,
+ bitrateValue: bitrateValueStr,
+ } = useLocalSearchParams<{
+ itemId: string;
+ audioIndex: string;
+ subtitleIndex: string;
+ mediaSourceId: string;
+ bitrateValue: string;
+ }>();
+
+ const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
+ const subtitleIndex = subtitleIndexStr
+ ? parseInt(subtitleIndexStr, 10)
+ : undefined;
+ const bitrateValue = bitrateValueStr
+ ? parseInt(bitrateValueStr, 10)
+ : undefined;
+
+ const {
+ data: item,
+ isLoading: isLoadingItem,
+ isError: isErrorItem,
+ } = useQuery({
+ queryKey: ["item", itemId],
+ queryFn: async () => {
+ if (!api) {
+ throw new Error("No api");
+ }
+
+ if (!itemId) {
+ console.warn("No itemId");
+ return null;
+ }
+
+ const res = await getUserLibraryApi(api).getItem({
+ itemId,
+ userId: user?.Id,
+ });
+
+ return res.data;
+ },
+ staleTime: 0,
+ });
+
+ // TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
+ // MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
+ const {
+ data: stream,
+ isLoading: isLoadingStreamUrl,
+ isError: isErrorStreamUrl,
+ } = useQuery({
+ queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
+
+ queryFn: async () => {
+ if (!api) {
+ throw new Error("No api");
+ }
+
+ if (!item) {
+ console.warn("No item", itemId, item);
+ return null;
+ }
+
+ const res = await getStreamUrl({
+ api,
+ item,
+ startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
+ userId: user?.Id,
+ audioStreamIndex: audioIndex,
+ maxStreamingBitrate: bitrateValue,
+ mediaSourceId: mediaSourceId,
+ subtitleStreamIndex: subtitleIndex,
+ deviceProfile: transcoding,
+ });
+
+ if (!res) return null;
+
+ const { mediaSource, sessionId, url } = res;
+
+ if (!sessionId || !mediaSource || !url) {
+ console.warn("No sessionId or mediaSource or url", url);
+ return null;
+ }
+
+ return {
+ mediaSource,
+ sessionId,
+ url,
+ };
+ },
+ enabled: !!item,
+ staleTime: 0,
+ });
+
+ const poster = usePoster(item, api);
+ const videoSource = useVideoSource(item, api, poster, stream?.url);
+
+ const togglePlay = useCallback(async () => {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ if (isPlaying) {
+ videoRef.current?.pause();
+ await getPlaystateApi(api!).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.floor(progress.value),
+ isPaused: true,
+ playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ } else {
+ videoRef.current?.resume();
+ await getPlaystateApi(api!).onPlaybackProgress({
+ itemId: item?.Id!,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.floor(progress.value),
+ isPaused: false,
+ playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ }
+ }, [
+ isPlaying,
+ api,
+ item,
+ videoRef,
+ settings,
+ stream,
+ audioIndex,
+ subtitleIndex,
+ mediaSourceId,
+ ]);
+
+ const play = useCallback(() => {
+ videoRef.current?.resume();
+ reportPlaybackStart();
+ }, [videoRef]);
+
+ const pause = useCallback(() => {
+ videoRef.current?.pause();
+ }, [videoRef]);
+
+ const seek = useCallback(
+ (seconds: number) => {
+ videoRef.current?.seek(seconds);
+ },
+ [videoRef]
+ );
+
+ const reportPlaybackStopped = async () => {
+ if (!item?.Id) return;
+ await getPlaystateApi(api!).onPlaybackStopped({
+ itemId: item.Id,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.floor(progress.value),
+ playSessionId: stream?.sessionId,
+ });
+ revalidateProgressCache();
+ };
+
+ const stop = useCallback(() => {
+ reportPlaybackStopped();
+ videoRef.current?.pause();
+ setIsPlaybackStopped(true);
+ }, [videoRef, reportPlaybackStopped]);
+
+ const reportPlaybackStart = async () => {
+ if (!item?.Id) return;
+ await getPlaystateApi(api!).onPlaybackStart({
+ itemId: item.Id,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ };
+
+ const onProgress = useCallback(
+ async (data: OnProgressData) => {
+ if (isSeeking.value === true) return;
+ if (isPlaybackStopped === true) return;
+
+ const ticks = secondsToTicks(data.currentTime);
+
+ progress.value = ticks;
+ cacheProgress.value = secondsToTicks(data.playableDuration);
+
+ console.log(
+ "onProgress ~",
+ ticks,
+ isPlaying,
+ `AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
+ );
+
+ // TODO: Use this when streaming with HLS url, but NOT when direct playing
+ // TODO: since playable duration is always 0 then.
+ setIsBuffering(data.playableDuration === 0);
+
+ if (!item?.Id || data.currentTime === 0) {
+ return;
+ }
+
+ await getPlaystateApi(api!).onPlaybackProgress({
+ itemId: item.Id,
+ audioStreamIndex: audioIndex ? audioIndex : undefined,
+ subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
+ mediaSourceId: mediaSourceId,
+ positionTicks: Math.round(ticks),
+ isPaused: !isPlaying,
+ playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
+ playSessionId: stream?.sessionId,
+ });
+ },
+ [
+ item,
+ isPlaying,
+ api,
+ isPlaybackStopped,
+ isSeeking,
+ stream,
+ mediaSourceId,
+ audioIndex,
+ subtitleIndex,
+ ]
+ );
+
+ useOrientation();
+ useOrientationSettings();
+
+ useWebSocket({
+ isPlaying: isPlaying,
+ togglePlay: togglePlay,
+ stopPlayback: stop,
+ offline: false,
+ });
+
+ const [selectedTextTrack, setSelectedTextTrack] = useState<
+ SelectedTrack | undefined
+ >();
+
+ const [embededTextTracks, setEmbededTextTracks] = useState<
+ {
+ index: number;
+ language?: string | undefined;
+ selected?: boolean | undefined;
+ title?: string | undefined;
+ type: any;
+ }[]
+ >([]);
+
+ const [audioTracks, setAudioTracks] = useState([]);
+ const [selectedAudioTrack, setSelectedAudioTrack] = useState<
+ SelectedTrack | undefined
+ >(undefined);
+
+ useEffect(() => {
+ if (selectedTextTrack === undefined) {
+ const subtitleHelper = new SubtitleHelper(
+ stream?.mediaSource.MediaStreams ?? []
+ );
+ const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
+ subtitleIndex!
+ );
+
+ // Most likely the subtitle is burned in.
+ if (embeddedTrackIndex === -1) return;
+ console.log(
+ "Setting selected text track",
+ subtitleIndex,
+ embeddedTrackIndex
+ );
+ setSelectedTextTrack({
+ type: SelectedTrackType.INDEX,
+ value: embeddedTrackIndex,
+ });
+ }
+ }, [embededTextTracks]);
+
+ const getAudioTracks = (): TrackInfo[] => {
+ return audioTracks.map((t) => ({
+ name: t.name,
+ index: t.index,
+ }));
+ };
+
+ const getSubtitleTracks = (): TrackInfo[] => {
+ return embededTextTracks.map((t) => ({
+ name: t.title ?? "",
+ index: t.index,
+ language: t.language,
+ }));
+ };
+
+ useFocusEffect(
+ React.useCallback(() => {
+ return async () => {
+ stop();
+ };
+ }, [])
+ );
+
+ if (isLoadingItem || isLoadingStreamUrl)
+ return (
+
+
+
+ );
+
+ if (isErrorItem || isErrorStreamUrl)
+ return (
+
+ Error
+
+ );
+
+ return (
+
+
+ {videoSource ? (
+ <>
+ {
+ console.error("Error playing video", e);
+ }}
+ onLoad={() => {
+ if (firstTime.current === true) {
+ play();
+ firstTime.current = false;
+ }
+ }}
+ progressUpdateInterval={500}
+ playWhenInactive={true}
+ allowsExternalPlayback={true}
+ playInBackground={true}
+ pictureInPicture={true}
+ showNotificationControls={true}
+ ignoreSilentSwitch="ignore"
+ fullscreen={false}
+ onPlaybackStateChanged={(state) => {
+ if (isSeeking.value === false) setIsPlaying(state.isPlaying);
+ }}
+ onTextTracks={(data) => {
+ setEmbededTextTracks(data.textTracks as any);
+ }}
+ onBuffer={(e) => {
+ setIsBuffering(e.isBuffering);
+ }}
+ onAudioTracks={(e) => {
+ console.log("onAudioTracks: ", e.audioTracks);
+ setAudioTracks(
+ e.audioTracks.map((t) => ({
+ index: t.index,
+ name: t.title ?? "",
+ language: t.language,
+ }))
+ );
+ }}
+ selectedTextTrack={selectedTextTrack}
+ selectedAudioTrack={selectedAudioTrack}
+ />
+ >
+ ) : (
+ No video source...
+ )}
+
+
+ {item && (
+ {
+ if (i === -1) {
+ setSelectedTextTrack({
+ type: SelectedTrackType.DISABLED,
+ value: undefined,
+ });
+ return;
+ }
+ setSelectedTextTrack({
+ type: SelectedTrackType.INDEX,
+ value: i,
+ });
+ }}
+ getAudioTracks={getAudioTracks}
+ setAudioTrack={(i) => {
+ console.log("setAudioTrack ~", i);
+ setSelectedAudioTrack({
+ type: SelectedTrackType.INDEX,
+ value: i,
+ });
+ }}
+ />
+ )}
+
+ );
+};
+
+export function usePoster(
+ item: BaseItemDto | null | undefined,
+ api: Api | null
+): string | undefined {
+ const poster = useMemo(() => {
+ if (!item || !api) return undefined;
+ return item.Type === "Audio"
+ ? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
+ : getBackdropUrl({
+ api,
+ item: item,
+ quality: 70,
+ width: 200,
+ });
+ }, [item, api]);
+
+ return poster ?? undefined;
+}
+
+export function useVideoSource(
+ item: BaseItemDto | null | undefined,
+ api: Api | null,
+ poster: string | undefined,
+ url?: string | null
+) {
+ const videoSource = useMemo(() => {
+ if (!item || !api || !url) {
+ return null;
+ }
+
+ const startPosition = item?.UserData?.PlaybackPositionTicks
+ ? Math.round(item.UserData.PlaybackPositionTicks / 10000)
+ : 0;
+
+ return {
+ uri: url,
+ isNetwork: true,
+ startPosition,
+ headers: getAuthHeaders(api),
+ metadata: {
+ artist: item?.AlbumArtist ?? undefined,
+ title: item?.Name || "Unknown",
+ description: item?.Overview ?? undefined,
+ imageUri: poster,
+ subtitle: item?.Album ?? undefined,
+ },
+ };
+ }, [item, api, poster, url]);
+
+ return videoSource;
+}
+
+export default Player;
diff --git a/app/(auth)/settings.tsx b/app/(auth)/settings.tsx
deleted file mode 100644
index 348d3a24..00000000
--- a/app/(auth)/settings.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Button } from "@/components/Button";
-import { Text } from "@/components/common/Text";
-import { ListItem } from "@/components/ListItem";
-import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
-import { clearLogs, readFromLog } from "@/utils/log";
-import { useQuery } from "@tanstack/react-query";
-import { useAtom } from "jotai";
-import { ScrollView, View } from "react-native";
-import * as Haptics from "expo-haptics";
-import { useFiles } from "@/hooks/useFiles";
-import { SettingToggles } from "@/components/settings/SettingToggles";
-
-export default function settings() {
- const { logout } = useJellyfin();
- const { deleteAllFiles } = useFiles();
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const { data: logs } = useQuery({
- queryKey: ["logs"],
- queryFn: async () => readFromLog(),
- refetchInterval: 1000,
- });
-
- return (
-
-
- Information
-
-
-
-
-
-
-
-
-
-
- Log out
-
- {
- await deleteAllFiles();
- Haptics.notificationAsync(
- Haptics.NotificationFeedbackType.Success,
- );
- }}
- >
- Delete all downloaded files
-
- {
- await clearLogs();
- Haptics.notificationAsync(
- Haptics.NotificationFeedbackType.Success,
- );
- }}
- >
- Delete all logs
-
-
-
- Logs
-
- {logs?.map((log, index) => (
-
-
- {log.level}
-
- {log.message}
-
- ))}
- {logs?.length === 0 && (
- No logs available
- )}
-
-
-
- );
-}
diff --git a/app/(auth)/songs/[songId].tsx b/app/(auth)/songs/[songId].tsx
deleted file mode 100644
index 4d1aa7f3..00000000
--- a/app/(auth)/songs/[songId].tsx
+++ /dev/null
@@ -1,281 +0,0 @@
-import { AudioTrackSelector } from "@/components/AudioTrackSelector";
-import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
-import { Chromecast } from "@/components/Chromecast";
-import { Text } from "@/components/common/Text";
-import {
- currentlyPlayingItemAtom,
- playingAtom,
-} from "@/components/CurrentlyPlayingBar";
-import { DownloadItem } from "@/components/DownloadItem";
-import { Loader } from "@/components/Loader";
-import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
-import { ParallaxScrollView } from "@/components/ParallaxPage";
-import { PlayButton } from "@/components/PlayButton";
-import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
-import { SimilarItems } from "@/components/SimilarItems";
-import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
-import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
-import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
-import { chromecastProfile } from "@/utils/profiles/chromecast";
-import ios from "@/utils/profiles/ios";
-import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
-import { Image } from "expo-image";
-import { useLocalSearchParams, useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { ScrollView, View } from "react-native";
-import CastContext, {
- PlayServicesState,
- useCastDevice,
- useRemoteMediaClient,
-} from "react-native-google-cast";
-
-const page: React.FC = () => {
- const local = useLocalSearchParams();
- const { songId: id } = local as { songId: string };
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const [, setPlaying] = useAtom(playingAtom);
-
- const castDevice = useCastDevice();
- const navigation = useNavigation();
-
- useEffect(() => {
- navigation.setOptions({
- headerRight: () => (
-
-
-
- ),
- });
- });
-
- const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
- const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
- const [selectedSubtitleStream, setSelectedSubtitleStream] =
- useState(0);
- const [maxBitrate, setMaxBitrate] = useState({
- key: "Max",
- value: undefined,
- });
-
- const { data: item, isLoading: l1 } = useQuery({
- queryKey: ["item", id],
- queryFn: async () =>
- await getUserItemData({
- api,
- userId: user?.Id,
- itemId: id,
- }),
- enabled: !!id && !!api,
- staleTime: 60 * 1000,
- });
-
- const backdropUrl = useMemo(
- () =>
- getBackdropUrl({
- api,
- item,
- quality: 90,
- width: 1000,
- }),
- [item]
- );
-
- const logoUrl = useMemo(
- () => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
- [item]
- );
-
- const { data: sessionData } = useQuery({
- queryKey: ["sessionData", item?.Id],
- queryFn: async () => {
- if (!api || !user?.Id || !item?.Id) return null;
- const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
- itemId: item?.Id,
- userId: user?.Id,
- });
-
- return playbackData.data;
- },
- enabled: !!item?.Id && !!api && !!user?.Id,
- staleTime: 0,
- });
-
- const { data: playbackUrl } = useQuery({
- queryKey: [
- "playbackUrl",
- item?.Id,
- maxBitrate,
- castDevice,
- selectedAudioStream,
- selectedSubtitleStream,
- ],
- queryFn: async () => {
- if (!api || !user?.Id || !sessionData) return null;
-
- const url = await getStreamUrl({
- api,
- userId: user.Id,
- item,
- startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
- maxStreamingBitrate: maxBitrate.value,
- sessionData,
- deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
- audioStreamIndex: selectedAudioStream,
- subtitleStreamIndex: selectedSubtitleStream,
- });
-
- console.log("Transcode URL: ", url);
-
- return url;
- },
- enabled: !!sessionData,
- staleTime: 0,
- });
-
- const [, setCp] = useAtom(currentlyPlayingItemAtom);
- const client = useRemoteMediaClient();
-
- const onPressPlay = useCallback(
- async (type: "device" | "cast" = "device") => {
- if (!playbackUrl || !item) return;
-
- if (type === "cast" && client) {
- await CastContext.getPlayServicesState().then((state) => {
- if (state && state !== PlayServicesState.SUCCESS)
- CastContext.showPlayServicesErrorDialog(state);
- else {
- client.loadMedia({
- mediaInfo: {
- contentUrl: playbackUrl,
- contentType: "video/mp4",
- metadata: {
- type: item.Type === "Episode" ? "tvShow" : "movie",
- title: item.Name || "",
- subtitle: item.Overview || "",
- },
- },
- startTime: 0,
- });
- }
- });
- } else {
- setCp({
- item,
- playbackUrl,
- });
- setPlaying(true);
- }
- },
- [playbackUrl, item]
- );
-
- if (l1)
- return (
-
-
-
- );
-
- if (!item?.Id || !backdropUrl) return null;
-
- return (
-
- }
- logo={
- <>
- {logoUrl ? (
-
- ) : null}
- >
- }
- >
-
-
-
- {item?.ProductionYear}
-
-
-
- {playbackUrl ? (
-
- ) : (
-
- )}
-
-
-
-
- setMaxBitrate(val)}
- selected={maxBitrate}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- Audio
-
-
-
- {item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default page;
diff --git a/app/_layout.tsx b/app/_layout.tsx
index e9fca8a8..0c4c9d07 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,31 +1,214 @@
-import { JellyfinProvider } from "@/providers/JellyfinProvider";
+import "@/augmentations";
+import { DownloadProvider } from "@/providers/DownloadProvider";
+import {
+ getOrSetDeviceId,
+ getTokenFromStorage,
+ JellyfinProvider,
+} from "@/providers/JellyfinProvider";
+import { JobQueueProvider } from "@/providers/JobQueueProvider";
+import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
+import { WebSocketProvider } from "@/providers/WebSocketProvider";
+import { orientationAtom } from "@/utils/atoms/orientation";
+import { Settings, useSettings } from "@/utils/atoms/settings";
+import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
+import { LogProvider, writeToLog } from "@/utils/log";
+import { storage } from "@/utils/mmkv";
+import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
+import { ActionSheetProvider } from "@expo/react-native-action-sheet";
+import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import {
+ checkForExistingDownloads,
+ completeHandler,
+ download,
+} from "@kesha-antonov/react-native-background-downloader";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import * as BackgroundFetch from "expo-background-fetch";
+import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
-import { Stack } from "expo-router";
-import * as SplashScreen from "expo-splash-screen";
-import { Provider as JotaiProvider } from "jotai";
-import { useEffect, useRef, useState } from "react";
-import "react-native-reanimated";
-import * as ScreenOrientation from "expo-screen-orientation";
-import { StatusBar } from "expo-status-bar";
-import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
-import { ActionSheetProvider } from "@expo/react-native-action-sheet";
-import { useJobProcessor } from "@/utils/atoms/queue";
-import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { useKeepAwake } from "expo-keep-awake";
-import { useSettings } from "@/utils/atoms/settings";
+import * as Linking from "expo-linking";
+import * as Notifications from "expo-notifications";
+import { router, Stack } from "expo-router";
+import * as ScreenOrientation from "expo-screen-orientation";
+import * as SplashScreen from "expo-splash-screen";
+import * as TaskManager from "expo-task-manager";
+import { Provider as JotaiProvider, useAtom } from "jotai";
+import { useEffect, useRef } from "react";
+import { Appearance, AppState } from "react-native";
+import { SystemBars } from "react-native-edge-to-edge";
import { GestureHandlerRootView } from "react-native-gesture-handler";
-import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { I18nextProvider, useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { getLocales } from "expo-localization";
+import "react-native-reanimated";
+import { Toaster } from "sonner-native";
-// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
-export const unstable_settings = {
- initialRouteName: "/index",
+Notifications.setNotificationHandler({
+ handleNotification: async () => ({
+ shouldShowAlert: true,
+ shouldPlaySound: true,
+ shouldSetBadge: false,
+ }),
+});
+
+function useNotificationObserver() {
+ useEffect(() => {
+ let isMounted = true;
+
+ function redirect(notification: Notifications.Notification) {
+ const url = notification.request.content.data?.url;
+ if (url) {
+ router.push(url);
+ }
+ }
+
+ Notifications.getLastNotificationResponseAsync().then((response) => {
+ if (!isMounted || !response?.notification) {
+ return;
+ }
+ redirect(response?.notification);
+ });
+
+ const subscription = Notifications.addNotificationResponseReceivedListener(
+ (response) => {
+ redirect(response.notification);
+ }
+ );
+
+ return () => {
+ isMounted = false;
+ subscription.remove();
+ };
+ }, []);
+}
+
+TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
+ console.log("TaskManager ~ trigger");
+
+ const now = Date.now();
+
+ const settingsData = storage.getString("settings");
+
+ if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
+
+ const settings: Partial = JSON.parse(settingsData);
+ const url = settings?.optimizedVersionsServerUrl;
+
+ if (!settings?.autoDownload || !url)
+ return BackgroundFetch.BackgroundFetchResult.NoData;
+
+ const token = getTokenFromStorage();
+ const deviceId = getOrSetDeviceId();
+ const baseDirectory = FileSystem.documentDirectory;
+
+ if (!token || !deviceId || !baseDirectory)
+ return BackgroundFetch.BackgroundFetchResult.NoData;
+
+ const jobs = await getAllJobsByDeviceId({
+ deviceId,
+ authHeader: token,
+ url,
+ });
+
+ console.log("TaskManager ~ Active jobs: ", jobs.length);
+
+ for (let job of jobs) {
+ if (job.status === "completed") {
+ const downloadUrl = url + "download/" + job.id;
+ const tasks = await checkForExistingDownloads();
+
+ if (tasks.find((task) => task.id === job.id)) {
+ console.log("TaskManager ~ Download already in progress: ", job.id);
+ continue;
+ }
+
+ download({
+ id: job.id,
+ url: downloadUrl,
+ destination: `${baseDirectory}${job.item.Id}.mp4`,
+ headers: {
+ Authorization: token,
+ },
+ })
+ .begin(() => {
+ console.log("TaskManager ~ Download started: ", job.id);
+ })
+ .done(() => {
+ console.log("TaskManager ~ Download completed: ", job.id);
+ saveDownloadedItemInfo(job.item);
+ completeHandler(job.id);
+ cancelJobById({
+ authHeader: token,
+ id: job.id,
+ url: url,
+ });
+ Notifications.scheduleNotificationAsync({
+ content: {
+ title: job.item.Name,
+ body: "Download completed",
+ data: {
+ url: `/downloads`,
+ },
+ },
+ trigger: null,
+ });
+ })
+ .error((error) => {
+ console.log("TaskManager ~ Download error: ", job.id, error);
+ completeHandler(job.id);
+ Notifications.scheduleNotificationAsync({
+ content: {
+ title: job.item.Name,
+ body: "Download failed",
+ data: {
+ url: `/downloads`,
+ },
+ },
+ trigger: null,
+ });
+ });
+ }
+ }
+
+ console.log(`Auto download started: ${new Date(now).toISOString()}`);
+
+ // Be sure to return the successful result type!
+ return BackgroundFetch.BackgroundFetchResult.NewData;
+});
+
+const checkAndRequestPermissions = async () => {
+ try {
+ const hasAskedBefore = storage.getString(
+ "hasAskedForNotificationPermission"
+ );
+
+ if (hasAskedBefore !== "true") {
+ const { status } = await Notifications.requestPermissionsAsync();
+
+ if (status === "granted") {
+ writeToLog("INFO", "Notification permissions granted.");
+ console.log("Notification permissions granted.");
+ } else {
+ writeToLog("ERROR", "Notification permissions denied.");
+ console.log("Notification permissions denied.");
+ }
+
+ storage.set("hasAskedForNotificationPermission", "true");
+ } else {
+ console.log("Already asked for notification permissions before.");
+ }
+ } catch (error) {
+ writeToLog(
+ "ERROR",
+ "Error checking/requesting notification permissions:",
+ error
+ );
+ console.error("Error checking/requesting notification permissions:", error);
+ }
};
export default function RootLayout() {
@@ -39,6 +222,8 @@ export default function RootLayout() {
}
}, [loaded]);
+ Appearance.setColorScheme("dark");
+
if (!loaded) {
return null;
}
@@ -52,26 +237,30 @@ export default function RootLayout() {
);
}
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 0,
+ refetchOnMount: true,
+ refetchOnReconnect: true,
+ refetchOnWindowFocus: true,
+ retryOnMount: true,
+ },
+ },
+});
+
function Layout() {
const [settings, updateSettings] = useSettings();
+ const [orientation, setOrientation] = useAtom(orientationAtom);
useKeepAwake();
+ useNotificationObserver();
const { i18n } = useTranslation();
- const queryClientRef = useRef(
- new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 60 * 1000,
- refetchOnMount: true,
- refetchOnReconnect: true,
- refetchOnWindowFocus: true,
- retryOnMount: true,
- },
- },
- })
- );
+ useEffect(() => {
+ checkAndRequestPermissions();
+ }, []);
useEffect(() => {
if (settings?.autoRotate === true)
@@ -88,111 +277,132 @@ function Layout() {
);
}, [settings]);
+ const appState = useRef(AppState.currentState);
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener("change", (nextAppState) => {
+ if (
+ appState.current.match(/inactive|background/) &&
+ nextAppState === "active"
+ ) {
+ checkForExistingDownloads();
+ }
+ });
+
+ checkForExistingDownloads();
+
+ return () => {
+ subscription.remove();
+ };
+ }, []);
+
+ useEffect(() => {
+ const subscription = ScreenOrientation.addOrientationChangeListener(
+ (event) => {
+ setOrientation(event.orientationInfo.orientation);
+ }
+ );
+
+ ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
+ setOrientation(initialOrientation);
+ });
+
+ return () => {
+ ScreenOrientation.removeOrientationChangeListener(subscription);
+ };
+ }, []);
+
+ const url = Linking.useURL();
+
+ if (url) {
+ const { hostname, path, queryParams } = Linking.parse(url);
+ }
+
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ null,
+ }}
+ />
+ null,
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
+
+function saveDownloadedItemInfo(item: BaseItemDto) {
+ try {
+ const downloadedItems = storage.getString("downloadedItems");
+ let items: BaseItemDto[] = downloadedItems
+ ? JSON.parse(downloadedItems)
+ : [];
+
+ const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
+ if (existingItemIndex !== -1) {
+ items[existingItemIndex] = item;
+ } else {
+ items.push(item);
+ }
+
+ storage.set("downloadedItems", JSON.stringify(items));
+ } catch (error) {
+ writeToLog("ERROR", "Failed to save downloaded item information:", error);
+ console.error("Failed to save downloaded item information:", error);
+ }
+}
diff --git a/app/login.tsx b/app/login.tsx
index 9d99f086..1e0b2813 100644
--- a/app/login.tsx
+++ b/app/login.tsx
@@ -4,15 +4,19 @@ import { Text } from "@/components/common/Text";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
-import { AxiosError } from "axios";
+import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
+import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
+import { Image } from "expo-image";
+import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
-import React, { useMemo, useState } from "react";
+import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Alert,
KeyboardAvoidingView,
Platform,
SafeAreaView,
+ TouchableOpacity,
View,
} from "react-native";
@@ -24,19 +28,64 @@ const CredentialsSchema = z.object({
const Login: React.FC = () => {
const { t, i18n } = useTranslation();
- const { setServer, login, removeServer } = useJellyfin();
+ const { setServer, login, removeServer, initiateQuickConnect } =
+ useJellyfin();
const [api] = useAtom(apiAtom);
+ const params = useLocalSearchParams();
- const [serverURL, setServerURL] = useState("");
+ const {
+ apiUrl: _apiUrl,
+ username: _username,
+ password: _password,
+ } = params as { apiUrl: string; username: string; password: string };
+
+ const [serverURL, setServerURL] = useState(_apiUrl);
+ const [serverName, setServerName] = useState("");
const [error, setError] = useState("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
- username: "",
- password: "",
+ username: _username,
+ password: _password,
});
+ useEffect(() => {
+ (async () => {
+ // we might re-use the checkUrl function here to check the url as well
+ // however, I don't think it should be necessary for now
+ if (_apiUrl) {
+ setServer({
+ address: _apiUrl,
+ });
+
+ setTimeout(() => {
+ if (_username && _password) {
+ setCredentials({ username: _username, password: _password });
+ login(_username, _password);
+ }
+ }, 300);
+ }
+ })();
+ }, [_apiUrl, _username, _password]);
+
+ const navigation = useNavigation();
+ useEffect(() => {
+ navigation.setOptions({
+ headerTitle: serverName,
+ headerLeft: () =>
+ api?.basePath ? (
+ {
+ removeServer();
+ }}
+ >
+
+
+ ) : null,
+ });
+ }, [serverName, navigation, api?.basePath]);
+
const [loading, setLoading] = useState(false);
const handleLogin = async () => {
@@ -47,19 +96,98 @@ const Login: React.FC = () => {
await login(credentials.username, credentials.password);
}
} catch (error) {
- const e = error as AxiosError;
- setError(e.message);
+ if (error instanceof Error) {
+ setError(error.message);
+ } else {
+ setError("An unexpected error occurred");
+ }
} finally {
setLoading(false);
}
};
- const handleConnect = (url: string) => {
- if (!url.startsWith("http")) {
- Alert.alert("Error", "URL needs to start with http or https.");
+ const [loadingServerCheck, setLoadingServerCheck] = useState(false);
+
+ /**
+ * Checks the availability and validity of a Jellyfin server URL.
+ *
+ * This function attempts to connect to a Jellyfin server using the provided URL.
+ * It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
+ *
+ * @param {string} url - The base URL of the Jellyfin server to check.
+ * @returns {Promise} A Promise that resolves to:
+ * - The full URL (including protocol) if a valid Jellyfin server is found.
+ * - undefined if no valid server is found at the given URL.
+ *
+ * Side effects:
+ * - Sets loadingServerCheck state to true at the beginning and false at the end.
+ * - Logs errors and timeout information to the console.
+ */
+ async function checkUrl(url: string) {
+ setLoadingServerCheck(true);
+
+ try {
+ const response = await fetch(`${url}/System/Info/Public`, {
+ mode: "cors",
+ });
+
+ if (response.ok) {
+ const data = (await response.json()) as PublicSystemInfo;
+ setServerName(data.ServerName || "");
+ return url;
+ }
+
+ return undefined;
+ } finally {
+ setLoadingServerCheck(false);
+ }
+ }
+
+ /**
+ * Handles the connection attempt to a Jellyfin server.
+ *
+ * This function trims the input URL, checks its validity using the `checkUrl` function,
+ * and sets the server address if a valid connection is established.
+ *
+ * @param {string} url - The URL of the Jellyfin server to connect to.
+ *
+ * @returns {Promise}
+ *
+ * Side effects:
+ * - Calls `checkUrl` to validate the server URL.
+ * - Shows an alert if the connection fails.
+ * - Sets the server address using `setServer` if the connection is successful.
+ *
+ */
+ const handleConnect = async (url: string) => {
+ url = url.trim();
+
+ const result = await checkUrl(url);
+
+ if (result === undefined) {
+ Alert.alert(
+ "Connection failed",
+ "Could not connect to the server. Please check the URL and your network connection."
+ );
return;
}
- setServer({ address: url.trim() });
+
+ setServer({ address: url });
+ };
+
+ const handleQuickConnect = async () => {
+ try {
+ const code = await initiateQuickConnect();
+ if (code) {
+ Alert.alert("Quick Connect", `Enter code ${code} to login`, [
+ {
+ text: "Got It",
+ },
+ ]);
+ }
+ } catch (error) {
+ Alert.alert("Error", "Failed to initiate Quick Connect");
+ }
};
if (api?.basePath) {
@@ -69,38 +197,21 @@ const Login: React.FC = () => {
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
>
-
-
-
-
- Streamyfin
-
- {t("server.server_label", { serverURL: api.basePath })}
-
- {
- removeServer();
- setServerURL("");
- }}
- justify="between"
- iconLeft={
-
- }
- >
- {t("server.change_server")}
-
-
-
+
+
- {t("login.login")}
-
- {t("login.login_subtitle")}
+
+ {t("login.login_button")}
+ <>
+ {serverName ? (
+ <>
+ {" to "}
+ {serverName}
+ >
+ ) : null}
+ >
+ {serverURL}
@@ -137,13 +248,18 @@ const Login: React.FC = () => {
{error}
-
+
+
+ Use Quick Connect
+
+
{t("login.login_button")}
-
+
+
@@ -154,14 +270,22 @@ const Login: React.FC = () => {
-
-
-
+
+
+
Streamyfin
- {t("server.connect_to_server")}
+ {t("server.enter_url_to_jellyfin_server")}
{
textContentType="URL"
maxLength={500}
/>
- {t("server.server_url_hint")}
+
+ {t("server.server_url_hint")}
+
- handleConnect(serverURL)} className="mb-2">
- {t("server.connect_button")}
-
+
+ await handleConnect(serverURL)}
+ className="w-full grow"
+ >
+ {t("server.connect_button")}
+
+
diff --git a/assets/Download_on_the_App_Store_Badge_DE_RGB_blk_092917.svg b/assets/Download_on_the_App_Store_Badge_DE_RGB_blk_092917.svg
new file mode 100644
index 00000000..536f3703
--- /dev/null
+++ b/assets/Download_on_the_App_Store_Badge_DE_RGB_blk_092917.svg
@@ -0,0 +1,40 @@
+
+ Download_on_the_App_Store_Badge_DE_RGB_blk_092917
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/Google_Play_Store_badge_EN.svg b/assets/Google_Play_Store_badge_EN.svg
new file mode 100644
index 00000000..0f7a5111
--- /dev/null
+++ b/assets/Google_Play_Store_badge_EN.svg
@@ -0,0 +1,2 @@
+
+image/svg+xml
\ No newline at end of file
diff --git a/assets/icons/house.fill.png b/assets/icons/house.fill.png
new file mode 100644
index 00000000..9e32f71e
Binary files /dev/null and b/assets/icons/house.fill.png differ
diff --git a/assets/icons/list.png b/assets/icons/list.png
new file mode 100644
index 00000000..3c548bb4
Binary files /dev/null and b/assets/icons/list.png differ
diff --git a/assets/icons/magnifyingglass.png b/assets/icons/magnifyingglass.png
new file mode 100644
index 00000000..5fc44c41
Binary files /dev/null and b/assets/icons/magnifyingglass.png differ
diff --git a/assets/icons/server.rack.png b/assets/icons/server.rack.png
new file mode 100644
index 00000000..245e5ad2
Binary files /dev/null and b/assets/icons/server.rack.png differ
diff --git a/assets/images/StreamyFinFinal.png b/assets/images/StreamyFinFinal.png
new file mode 100644
index 00000000..5800cf38
Binary files /dev/null and b/assets/images/StreamyFinFinal.png differ
diff --git a/assets/images/adaptive_icon.png b/assets/images/adaptive_icon.png
new file mode 100644
index 00000000..8443e717
Binary files /dev/null and b/assets/images/adaptive_icon.png differ
diff --git a/assets/images/icon.jpg b/assets/images/icon.jpg
deleted file mode 100644
index 3c1892b4..00000000
Binary files a/assets/images/icon.jpg and /dev/null differ
diff --git a/assets/images/icon.png b/assets/images/icon.png
index 4382a2ff..7011bb09 100644
Binary files a/assets/images/icon.png and b/assets/images/icon.png differ
diff --git a/assets/images/icon_512x512.jpg b/assets/images/icon_512x512.jpg
deleted file mode 100644
index 24cbd3c0..00000000
Binary files a/assets/images/icon_512x512.jpg and /dev/null differ
diff --git a/assets/images/screenshots/androidscreen.png b/assets/images/screenshots/androidscreen.png
new file mode 100644
index 00000000..3c7ad0dd
Binary files /dev/null and b/assets/images/screenshots/androidscreen.png differ
diff --git a/assets/images/screenshots/screenshot1.png b/assets/images/screenshots/screenshot1.png
new file mode 100644
index 00000000..111573ca
Binary files /dev/null and b/assets/images/screenshots/screenshot1.png differ
diff --git a/assets/images/screenshots/screenshot2.png b/assets/images/screenshots/screenshot2.png
new file mode 100644
index 00000000..183389f4
Binary files /dev/null and b/assets/images/screenshots/screenshot2.png differ
diff --git a/assets/images/screenshots/screenshot3.png b/assets/images/screenshots/screenshot3.png
new file mode 100644
index 00000000..af1d4d77
Binary files /dev/null and b/assets/images/screenshots/screenshot3.png differ
diff --git a/assets/images/screenshots/screenshot4.png b/assets/images/screenshots/screenshot4.png
new file mode 100644
index 00000000..d72a93fe
Binary files /dev/null and b/assets/images/screenshots/screenshot4.png differ
diff --git a/assets/images/splash.jpg b/assets/images/splash.jpg
deleted file mode 100644
index 96fabe80..00000000
Binary files a/assets/images/splash.jpg and /dev/null differ
diff --git a/assets/images/splash.png b/assets/images/splash.png
index 2d7bf575..106487b2 100644
Binary files a/assets/images/splash.png and b/assets/images/splash.png differ
diff --git a/augmentations/index.ts b/augmentations/index.ts
new file mode 100644
index 00000000..22ca2cb0
--- /dev/null
+++ b/augmentations/index.ts
@@ -0,0 +1,3 @@
+export * from "./mmkv";
+export * from "./number";
+export * from "./string";
diff --git a/augmentations/mmkv.ts b/augmentations/mmkv.ts
new file mode 100644
index 00000000..80fbeede
--- /dev/null
+++ b/augmentations/mmkv.ts
@@ -0,0 +1,17 @@
+import {MMKV} from "react-native-mmkv";
+
+declare module "react-native-mmkv" {
+ interface MMKV {
+ get(key: string): T | undefined
+ setAny(key: string, value: any | undefined): void
+ }
+}
+
+MMKV.prototype.get = function (key: string): T | undefined {
+ const serializedItem = this.getString(key);
+ return serializedItem ? JSON.parse(serializedItem) : undefined;
+}
+
+MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
+ this.set(key, JSON.stringify(value));
+}
\ No newline at end of file
diff --git a/augmentations/number.ts b/augmentations/number.ts
new file mode 100644
index 00000000..2b7e8dac
--- /dev/null
+++ b/augmentations/number.ts
@@ -0,0 +1,37 @@
+declare global {
+ interface Number {
+ bytesToReadable(): string;
+ secondsToMilliseconds(): number
+ minutesToMilliseconds(): number
+ hoursToMilliseconds(): number
+ }
+}
+
+Number.prototype.bytesToReadable = function () {
+ const bytes = this.valueOf();
+ const gb = bytes / 1e9;
+
+ if (gb >= 1) return `${gb.toFixed(2)} GB`;
+
+ const mb = bytes / 1024.0 / 1024.0;
+ if (mb >= 1) return `${mb.toFixed(2)} MB`;
+
+ const kb = bytes / 1024.0;
+ if (kb >= 1) return `${kb.toFixed(2)} KB`;
+
+ return `${bytes.toFixed(2)} B`;
+}
+
+Number.prototype.secondsToMilliseconds = function () {
+ return this.valueOf() * 1000
+}
+
+Number.prototype.minutesToMilliseconds = function () {
+ return this.valueOf() * (60).secondsToMilliseconds()
+}
+
+Number.prototype.hoursToMilliseconds = function () {
+ return this.valueOf() * (60).minutesToMilliseconds()
+}
+
+export {};
\ No newline at end of file
diff --git a/augmentations/string.ts b/augmentations/string.ts
new file mode 100644
index 00000000..75a97f05
--- /dev/null
+++ b/augmentations/string.ts
@@ -0,0 +1,16 @@
+declare global {
+ interface String {
+ toTitle(): string;
+ }
+}
+
+String.prototype.toTitle = function () {
+ return this
+ .replaceAll("_", " ")
+ .replace(
+ /\w\S*/g,
+ text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
+ );
+}
+
+export {};
\ No newline at end of file
diff --git a/bun.lockb b/bun.lockb
index cf902f6f..e34e84fe 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/components/AudioTrackSelector.tsx b/components/AudioTrackSelector.tsx
index 3ee90937..75fd659c 100644
--- a/components/AudioTrackSelector.tsx
+++ b/components/AudioTrackSelector.tsx
@@ -1,53 +1,47 @@
+import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
+import { useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
-import { atom, useAtom } from "jotai";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useEffect, useMemo } from "react";
-import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
-import { tc } from "@/utils/textTools";
interface Props extends React.ComponentProps {
- item: BaseItemDto;
+ source?: MediaSourceInfo;
onChange: (value: number) => void;
- selected: number;
+ selected?: number | undefined;
}
export const AudioTrackSelector: React.FC = ({
- item,
+ source,
onChange,
selected,
...props
}) => {
const audioStreams = useMemo(
- () =>
- item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
- [item],
+ () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
+ [source]
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
- [audioStreams, selected],
+ [audioStreams, selected]
);
- useEffect(() => {
- const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
- if (index !== undefined && index !== null) onChange(index);
- }, []);
-
return (
-
+
-
- Audio streams
-
-
-
- {tc(selectedAudioSteam?.DisplayTitle, 13)}
-
-
-
+
+ Audio
+
+
+ {selectedAudioSteam?.DisplayTitle}
+
+
(b.value || Infinity) - (a.value || Infinity));
interface Props extends React.ComponentProps {
onChange: (value: Bitrate) => void;
- selected: Bitrate;
+ selected?: Bitrate | null;
+ inverted?: boolean | null;
}
export const BitrateSelector: React.FC = ({
onChange,
selected,
+ inverted,
...props
}) => {
+ const sorted = useMemo(() => {
+ if (inverted)
+ return BITRATES.sort(
+ (a, b) => (a.value || Infinity) - (b.value || Infinity)
+ );
+ return BITRATES.sort(
+ (a, b) => (b.value || Infinity) - (a.value || Infinity)
+ );
+ }, []);
+
return (
-
+
-
- Bitrate
-
-
-
- {BITRATES.find((b) => b.value === selected.value)?.key}
-
-
-
+
+ Quality
+
+
+ {BITRATES.find((b) => b.value === selected?.value)?.key}
+
+
Bitrates
- {BITRATES?.map((b, index: number) => (
+ {sorted.map((b) => (
{
onChange(b);
}}
diff --git a/components/Button.tsx b/components/Button.tsx
index 813c4222..1498a975 100644
--- a/components/Button.tsx
+++ b/components/Button.tsx
@@ -3,14 +3,15 @@ import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
-interface ButtonProps extends React.ComponentProps {
+export interface ButtonProps
+ extends React.ComponentProps {
onPress?: () => void;
className?: string;
textClassName?: string;
disabled?: boolean;
children?: string | ReactNode;
loading?: boolean;
- color?: "purple" | "red" | "black";
+ color?: "purple" | "red" | "black" | "transparent";
iconRight?: ReactNode;
iconLeft?: ReactNode;
justify?: "center" | "between";
@@ -37,6 +38,8 @@ export const Button: React.FC> = ({
return "bg-red-600";
case "black":
return "bg-neutral-900 border border-neutral-800";
+ case "transparent":
+ return "bg-transparent";
}
}, [color]);
diff --git a/components/Chromecast.tsx b/components/Chromecast.tsx
index bf92148b..3e5c8546 100644
--- a/components/Chromecast.tsx
+++ b/components/Chromecast.tsx
@@ -1,25 +1,35 @@
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import React, { useEffect } from "react";
-import { View } from "react-native";
-import {
+import { Feather } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import React, { useCallback, useEffect } from "react";
+import { Platform, TouchableOpacity, ViewProps } from "react-native";
+import GoogleCast, {
CastButton,
+ CastContext,
useCastDevice,
useDevices,
+ useMediaStatus,
useRemoteMediaClient,
} from "react-native-google-cast";
-import GoogleCast from "react-native-google-cast";
+import { RoundButton } from "./RoundButton";
-type Props = {
+interface Props extends ViewProps {
width?: number;
height?: number;
-};
+ background?: "blur" | "transparent";
+}
-export const Chromecast: React.FC = ({ width = 48, height = 48 }) => {
+export const Chromecast: React.FC = ({
+ width = 48,
+ height = 48,
+ background = "transparent",
+ ...props
+}) => {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
+ const mediaStatus = useMediaStatus();
useEffect(() => {
(async () => {
@@ -31,9 +41,43 @@ export const Chromecast: React.FC = ({ width = 48, height = 48 }) => {
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
+ // Android requires the cast button to be present for startDiscovery to work
+ const AndroidCastButton = useCallback(
+ () =>
+ Platform.OS === "android" ? (
+
+ ) : (
+ <>>
+ ),
+ [Platform.OS]
+ );
+
+ if (background === "transparent")
+ return (
+ {
+ if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
+ else CastContext.showCastDialog();
+ }}
+ {...props}
+ >
+
+
+ );
+
return (
-
-
-
+ {
+ if (mediaStatus?.currentItemId) CastContext.showExpandedControls();
+ else CastContext.showCastDialog();
+ }}
+ {...props}
+ >
+
+
);
};
diff --git a/components/ContinueWatchingPoster.tsx b/components/ContinueWatchingPoster.tsx
index 895e91d3..eb000d45 100644
--- a/components/ContinueWatchingPoster.tsx
+++ b/components/ContinueWatchingPoster.tsx
@@ -1,61 +1,103 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
-import { useAtom } from "jotai";
-import { useMemo, useState } from "react";
+import { useAtomValue } from "jotai";
+import { useMemo } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import React from "react";
+import { Ionicons } from "@expo/vector-icons";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
+ useEpisodePoster?: boolean;
+ size?: "small" | "normal";
+ showPlayButton?: boolean;
};
const ContinueWatchingPoster: React.FC = ({
item,
+ useEpisodePoster = false,
+ size = "normal",
+ showPlayButton = false,
}) => {
- const [api] = useAtom(apiAtom);
+ const api = useAtomValue(apiAtom);
- const url = useMemo(
- () =>
- getPrimaryImageUrl({
- api,
- item,
- quality: 70,
- width: 300,
- }),
- [item],
- );
+ /**
+ * Get horizontal poster for movie and episode, with failover to primary.
+ */
+ const url = useMemo(() => {
+ if (!api) return;
+ if (item.Type === "Episode" && useEpisodePoster) {
+ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
+ }
+ if (item.Type === "Episode") {
+ if (item.ParentBackdropItemId && item.ParentThumbImageTag)
+ return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
+ else
+ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
+ }
+ if (item.Type === "Movie") {
+ if (item.ImageTags?.["Thumb"])
+ return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
+ else
+ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
+ }
+ if (item.Type === "Program") {
+ if (item.ImageTags?.["Thumb"])
+ return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.["Thumb"]}`;
+ else
+ return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
+ }
+ }, [item]);
- const [progress, setProgress] = useState(
- item.UserData?.PlayedPercentage || 0,
- );
+ const progress = useMemo(() => {
+ if (item.Type === "Program") {
+ const startDate = new Date(item.StartDate || "");
+ const endDate = new Date(item.EndDate || "");
+ const now = new Date();
+ const total = endDate.getTime() - startDate.getTime();
+ const elapsed = now.getTime() - startDate.getTime();
+ return (elapsed / total) * 100;
+ } else {
+ return item.UserData?.PlayedPercentage || 0;
+ }
+ }, [item]);
if (!url)
return (
-
+
);
return (
-
-
+
+
+
+ {showPlayButton && (
+
+
+
+ )}
+
{!progress && }
{progress > 0 && (
<>
(null);
-
-export const playingAtom = atom(false);
-export const fullScreenAtom = atom(false);
-
-export const CurrentlyPlayingBar: React.FC = () => {
- const queryClient = useQueryClient();
- const segments = useSegments();
-
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
- const [playing, setPlaying] = useAtom(playingAtom);
- const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
- currentlyPlayingItemAtom
- );
- const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
-
- const videoRef = useRef(null);
- const [progress, setProgress] = useState(0);
-
- const aBottom = useSharedValue(0);
- const aPadding = useSharedValue(0);
- const aHeight = useSharedValue(100);
- const router = useRouter();
- const animatedOuterStyle = useAnimatedStyle(() => {
- return {
- bottom: withTiming(aBottom.value, { duration: 500 }),
- height: withTiming(aHeight.value, { duration: 500 }),
- padding: withTiming(aPadding.value, { duration: 500 }),
- };
- });
-
- const aPaddingBottom = useSharedValue(30);
- const aPaddingInner = useSharedValue(12);
- const aBorderRadiusBottom = useSharedValue(12);
- const animatedInnerStyle = useAnimatedStyle(() => {
- return {
- padding: withTiming(aPaddingInner.value, { duration: 500 }),
- paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
- borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
- duration: 500,
- }),
- borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
- duration: 500,
- }),
- };
- });
-
- useEffect(() => {
- if (segments.find((s) => s.includes("tabs"))) {
- // Tab screen - i.e. home
- aBottom.value = Platform.OS === "ios" ? 78 : 50;
- aHeight.value = 80;
- aPadding.value = 8;
- aPaddingBottom.value = 8;
- aPaddingInner.value = 8;
- } else {
- // Inside a normal screen
- aBottom.value = Platform.OS === "ios" ? 0 : 0;
- aHeight.value = Platform.OS === "ios" ? 110 : 80;
- aPadding.value = Platform.OS === "ios" ? 0 : 8;
- aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
- aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
- }
- }, [segments]);
-
- const { data: item } = useQuery({
- queryKey: ["item", currentlyPlaying?.item.Id],
- queryFn: async () =>
- await getUserItemData({
- api,
- userId: user?.Id,
- itemId: currentlyPlaying?.item.Id,
- }),
- enabled: !!currentlyPlaying?.item.Id && !!api,
- staleTime: 60,
- });
-
- const { data: sessionData } = useQuery({
- queryKey: ["sessionData", currentlyPlaying?.item.Id],
- queryFn: async () => {
- if (!currentlyPlaying?.item.Id) return null;
- const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
- itemId: currentlyPlaying?.item.Id,
- userId: user?.Id,
- });
- return playbackData.data;
- },
- enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
- staleTime: 0,
- });
-
- const onProgress = useCallback(
- ({ currentTime }: OnProgressData) => {
- if (
- !currentTime ||
- !sessionData?.PlaySessionId ||
- !playing ||
- !api ||
- !currentlyPlaying?.item.Id
- )
- return;
- const newProgress = currentTime * 10000000;
- setProgress(newProgress);
- reportPlaybackProgress({
- api,
- itemId: currentlyPlaying?.item.Id,
- positionTicks: newProgress,
- sessionId: sessionData.PlaySessionId,
- });
- },
- [sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
- );
-
- useEffect(() => {
- if (!item || !api) return;
-
- if (playing) {
- videoRef.current?.resume();
- } else {
- videoRef.current?.pause();
- if (progress > 0 && sessionData?.PlaySessionId)
- reportPlaybackStopped({
- api,
- itemId: item?.Id,
- positionTicks: progress,
- sessionId: sessionData?.PlaySessionId,
- });
-
- queryClient.invalidateQueries({
- queryKey: ["nextUp", item?.SeriesId],
- refetchType: "all",
- });
- queryClient.invalidateQueries({
- queryKey: ["episodes"],
- refetchType: "all",
- });
- }
- }, [playing, progress, item, sessionData]);
-
- useEffect(() => {
- if (fullScreen === true) {
- videoRef.current?.presentFullscreenPlayer();
- } else {
- videoRef.current?.dismissFullscreenPlayer();
- }
- }, [fullScreen]);
-
- const startPosition = useMemo(
- () =>
- item?.UserData?.PlaybackPositionTicks
- ? Math.round(item.UserData.PlaybackPositionTicks / 10000)
- : 0,
- [item]
- );
-
- const backdropUrl = useMemo(
- () =>
- getBackdropUrl({
- api,
- item,
- quality: 70,
- width: 200,
- }),
- [item]
- );
-
- if (!currentlyPlaying || !api) return null;
-
- return (
-
-
-
-
- {
- videoRef.current?.presentFullscreenPlayer();
- }}
- className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
- ${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
- `}
- >
- {currentlyPlaying.playbackUrl && (
- onProgress(e)}
- subtitleStyle={{
- fontSize: 16,
- }}
- source={{
- uri: currentlyPlaying.playbackUrl,
- isNetwork: true,
- startPosition,
- headers: getAuthHeaders(api),
- }}
- onBuffer={(e) =>
- e.isBuffering ? console.log("Buffering...") : null
- }
- onFullscreenPlayerDidDismiss={() => {
- setFullScreen(false);
- }}
- onFullscreenPlayerDidPresent={() => {
- setFullScreen(true);
- }}
- onPlaybackStateChanged={(e) => {
- if (e.isPlaying) {
- setPlaying(true);
- } else if (e.isSeeking) {
- return;
- } else {
- setPlaying(false);
- }
- }}
- progressUpdateInterval={1000}
- onError={(e) => {
- console.log(e);
- writeToLog(
- "ERROR",
- "Video playback error: " + JSON.stringify(e)
- );
- Alert.alert("Error", "Cannot play this video file.");
- setPlaying(false);
- setCurrentlyPlaying(null);
- }}
- renderLoader={
- item?.Type !== "Audio" && (
-
-
-
- )
- }
- />
- )}
-
-
- {
- if (item?.Type === "Audio")
- router.push(`/albums/${item?.AlbumId}`);
- else router.push(`/items/${item?.Id}`);
- }}
- >
- {item?.Name}
-
- {item?.Type === "Episode" && (
- {
- router.push(`/(auth)/series/${item.SeriesId}`);
- }}
- className="text-xs opacity-50"
- >
- {item.SeriesName}
-
- )}
- {item?.Type === "Movie" && (
-
-
- {item?.ProductionYear}
-
-
- )}
- {item?.Type === "Audio" && (
- {
- router.push(`/albums/${item?.AlbumId}`);
- }}
- >
- {item?.Album}
-
- )}
-
-
-
- {
- if (playing) setPlaying(false);
- else setPlaying(true);
- }}
- className="aspect-square rounded flex flex-col items-center justify-center p-2"
- >
- {playing ? (
-
- ) : (
-
- )}
-
- {
- setCurrentlyPlaying(null);
- }}
- className="aspect-square rounded flex flex-col items-center justify-center p-2"
- >
-
-
-
-
-
-
- );
-};
diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx
index e58a5a3e..4618bb4f 100644
--- a/components/DownloadItem.tsx
+++ b/components/DownloadItem.tsx
@@ -1,138 +1,405 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
+import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
-import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
+import { useSettings } from "@/utils/atoms/settings";
+import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
+import download from "@/utils/profiles/download";
import Ionicons from "@expo/vector-icons/Ionicons";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { useQuery } from "@tanstack/react-query";
-import { router } from "expo-router";
+import {
+ BottomSheetBackdrop,
+ BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { Href, router, useFocusEffect } from "expo-router";
import { useAtom } from "jotai";
-import { TouchableOpacity, View } from "react-native";
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import { Alert, View, ViewProps } from "react-native";
+import { toast } from "sonner-native";
+import { AudioTrackSelector } from "./AudioTrackSelector";
+import { Bitrate, BitrateSelector } from "./BitrateSelector";
+import { Button } from "./Button";
+import { Text } from "./common/Text";
import { Loader } from "./Loader";
+import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
+import { RoundButton } from "./RoundButton";
+import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
-type DownloadProps = {
- item: BaseItemDto;
- playbackUrl: string;
-};
+interface DownloadProps extends ViewProps {
+ items: BaseItemDto[];
+ MissingDownloadIconComponent: () => React.ReactElement;
+ DownloadedIconComponent: () => React.ReactElement;
+ title?: string;
+ subtitle?: string;
+ size?: "default" | "large";
+}
-export const DownloadItem: React.FC = ({
- item,
- playbackUrl,
+export const DownloadItems: React.FC = ({
+ items,
+ MissingDownloadIconComponent,
+ DownloadedIconComponent,
+ title = "Download",
+ subtitle = "",
+ size = "default",
+ ...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
- const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
+ const [settings] = useSettings();
+ const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
+ const { startRemuxing } = useRemuxHlsToMp4();
- const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
-
- const { data: playbackInfo, isLoading } = useQuery({
- queryKey: ["playbackInfo", item.Id],
- queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
+ const [selectedMediaSource, setSelectedMediaSource] = useState<
+ MediaSourceInfo | undefined | null
+ >(undefined);
+ const [selectedAudioStream, setSelectedAudioStream] = useState(-1);
+ const [selectedSubtitleStream, setSelectedSubtitleStream] =
+ useState(0);
+ const [maxBitrate, setMaxBitrate] = useState({
+ key: "Max",
+ value: undefined,
});
- const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
- queryKey: ["downloaded", item.Id],
- queryFn: async () => {
- if (!item.Id) return false;
+ const userCanDownload = useMemo(
+ () => user?.Policy?.EnableContentDownloading,
+ [user]
+ );
+ const usingOptimizedServer = useMemo(
+ () => settings?.downloadMethod === "optimized",
+ [settings]
+ );
- const data: BaseItemDto[] = JSON.parse(
- (await AsyncStorage.getItem("downloaded_files")) || "[]"
- );
+ const bottomSheetModalRef = useRef(null);
- return data.some((d) => d.Id === item.Id);
- },
- enabled: !!item.Id,
- });
+ const handlePresentModalPress = useCallback(() => {
+ bottomSheetModalRef.current?.present();
+ }, []);
- if (isLoading || isLoadingDownloaded) {
+ const handleSheetChanges = useCallback((index: number) => {}, []);
+
+ const closeModal = useCallback(() => {
+ bottomSheetModalRef.current?.dismiss();
+ }, []);
+
+ const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
+
+ const itemsNotDownloaded = useMemo(
+ () =>
+ items.filter((i) => !downloadedFiles?.some((f) => f.item.Id === i.Id)),
+ [items, downloadedFiles]
+ );
+
+ const allItemsDownloaded = useMemo(() => {
+ if (items.length === 0) return false;
+ return itemsNotDownloaded.length === 0;
+ }, [items, itemsNotDownloaded]);
+ const itemsProcesses = useMemo(
+ () => processes?.filter((p) => itemIds.includes(p.item.Id)),
+ [processes, itemIds]
+ );
+
+ const progress = useMemo(() => {
+ if (itemIds.length == 1)
+ return itemsProcesses.reduce((acc, p) => acc + p.progress, 0);
return (
-
-
-
+ ((itemIds.length -
+ queue.filter((q) => itemIds.includes(q.item.Id)).length) /
+ itemIds.length) *
+ 100
);
- }
+ }, [queue, itemsProcesses, itemIds]);
- if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
+ const itemsQueued = useMemo(() => {
return (
-
-
-
+ itemsNotDownloaded.length > 0 &&
+ itemsNotDownloaded.every((p) => queue.some((q) => p.Id == q.item.Id))
);
- }
+ }, [queue, itemsNotDownloaded]);
+ const navigateToDownloads = () => router.push("/downloads");
- if (process && process?.item.Id === item.Id) {
- return (
- {
- router.push("/downloads");
- }}
- >
-
- {process.progress === 0 ? (
-
- ) : (
-
-
-
- )}
-
-
- );
- }
-
- if (queue.some((i) => i.id === item.Id)) {
- return (
- {
- router.push("/downloads");
- }}
- >
-
-
-
-
- );
- }
-
- if (downloaded) {
- return (
- {
- router.push("/downloads");
- }}
- >
-
-
-
-
- );
- } else {
- return (
- {
- queueActions.enqueue(queue, setQueue, {
- id: item.Id!,
- execute: async () => {
- await startRemuxing();
+ const onDownloadedPress = () => {
+ const firstItem = items?.[0];
+ router.push(
+ firstItem.Type !== "Episode"
+ ? "/downloads"
+ : ({
+ pathname: `/downloads/${firstItem.SeriesId}`,
+ params: {
+ episodeSeasonIndex: firstItem.ParentIndexNumber,
},
- item,
- });
- }}
- >
-
-
-
-
+ } as Href)
);
- }
+ };
+
+ const acceptDownloadOptions = useCallback(() => {
+ if (userCanDownload === true) {
+ if (itemsNotDownloaded.some((i) => !i.Id)) {
+ throw new Error("No item id");
+ }
+ closeModal();
+
+ if (usingOptimizedServer) initiateDownload(...itemsNotDownloaded);
+ else {
+ queueActions.enqueue(
+ queue,
+ setQueue,
+ ...itemsNotDownloaded.map((item) => ({
+ id: item.Id!,
+ execute: async () => await initiateDownload(item),
+ item,
+ }))
+ );
+ }
+ } else {
+ toast.error("You are not allowed to download files.");
+ }
+ }, [
+ queue,
+ setQueue,
+ itemsNotDownloaded,
+ usingOptimizedServer,
+ userCanDownload,
+ maxBitrate,
+ selectedMediaSource,
+ selectedAudioStream,
+ selectedSubtitleStream,
+ ]);
+
+ const initiateDownload = useCallback(
+ async (...items: BaseItemDto[]) => {
+ if (
+ !api ||
+ !user?.Id ||
+ items.some((p) => !p.Id) ||
+ (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
+ ) {
+ throw new Error(
+ "DownloadItem ~ initiateDownload: No api or user or item"
+ );
+ }
+ let mediaSource = selectedMediaSource;
+ let audioIndex: number | undefined = selectedAudioStream;
+ let subtitleIndex: number | undefined = selectedSubtitleStream;
+
+ for (const item of items) {
+ if (itemsNotDownloaded.length > 1) {
+ ({ mediaSource, audioIndex, subtitleIndex } = getDefaultPlaySettings(
+ item,
+ settings!
+ ));
+ }
+
+ const res = await getStreamUrl({
+ api,
+ item,
+ startTimeTicks: 0,
+ userId: user?.Id,
+ audioStreamIndex: audioIndex,
+ maxStreamingBitrate: maxBitrate.value,
+ mediaSourceId: mediaSource?.Id,
+ subtitleStreamIndex: subtitleIndex,
+ deviceProfile: download,
+ });
+
+ if (!res) {
+ Alert.alert(
+ "Something went wrong",
+ "Could not get stream url from Jellyfin"
+ );
+ continue;
+ }
+
+ const { mediaSource: source, url } = res;
+
+ if (!url || !source) throw new Error("No url");
+
+ saveDownloadItemInfoToDiskTmp(item, source, url);
+
+ if (usingOptimizedServer) {
+ await startBackgroundDownload(url, item, source);
+ } else {
+ await startRemuxing(item, url, source);
+ }
+ }
+ },
+ [
+ api,
+ user?.Id,
+ itemsNotDownloaded,
+ selectedMediaSource,
+ selectedAudioStream,
+ selectedSubtitleStream,
+ settings,
+ maxBitrate,
+ usingOptimizedServer,
+ startBackgroundDownload,
+ startRemuxing,
+ ]
+ );
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ []
+ );
+ useFocusEffect(
+ useCallback(() => {
+ if (!settings) return;
+ if (itemsNotDownloaded.length !== 1) return;
+ const { bitrate, mediaSource, audioIndex, subtitleIndex } =
+ getDefaultPlaySettings(items[0], settings);
+
+ setSelectedMediaSource(mediaSource ?? undefined);
+ setSelectedAudioStream(audioIndex ?? 0);
+ setSelectedSubtitleStream(subtitleIndex ?? -1);
+ setMaxBitrate(bitrate);
+ }, [items, itemsNotDownloaded, settings])
+ );
+
+ const renderButtonContent = () => {
+ if (processes && itemsProcesses.length > 0) {
+ return progress === 0 ? (
+
+ ) : (
+
+
+
+ );
+ } else if (itemsQueued) {
+ return ;
+ } else if (allItemsDownloaded) {
+ return ;
+ } else {
+ return ;
+ }
+ };
+
+ const onButtonPress = () => {
+ if (processes && itemsProcesses.length > 0) {
+ navigateToDownloads();
+ } else if (itemsQueued) {
+ navigateToDownloads();
+ } else if (allItemsDownloaded) {
+ onDownloadedPress();
+ } else {
+ handlePresentModalPress();
+ }
+ };
+
+ return (
+
+
+ {renderButtonContent()}
+
+
+
+
+
+
+ {title}
+
+
+ {subtitle || `Download ${itemsNotDownloaded.length} items`}
+
+
+
+
+ {itemsNotDownloaded.length === 1 && (
+ <>
+
+ {selectedMediaSource && (
+
+
+
+
+ )}
+ >
+ )}
+
+
+ Download
+
+
+
+ {usingOptimizedServer
+ ? "Using optimized server"
+ : "Using default method"}
+
+
+
+
+
+
+ );
+};
+
+export const DownloadSingleItem: React.FC<{
+ size?: "default" | "large";
+ item: BaseItemDto;
+}> = ({ item, size = "default" }) => {
+ return (
+ (
+
+ )}
+ DownloadedIconComponent={() => (
+
+ )}
+ />
+ );
};
diff --git a/components/GenreTags.tsx b/components/GenreTags.tsx
new file mode 100644
index 00000000..c90ede32
--- /dev/null
+++ b/components/GenreTags.tsx
@@ -0,0 +1,43 @@
+// GenreTags.tsx
+import React from "react";
+import {View, ViewProps} from "react-native";
+import { Text } from "./common/Text";
+
+interface TagProps {
+ tags?: string[];
+ textClass?: ViewProps["className"]
+}
+
+export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
+ text,
+ textClass,
+ ...props
+}) => {
+ return (
+
+ {text}
+
+ );
+};
+
+export const Tags: React.FC = ({ tags, textClass = "text-xs", ...props }) => {
+ if (!tags || tags.length === 0) return null;
+
+ return (
+
+ {tags.map((tag, idx) => (
+
+
+
+ ))}
+
+ );
+};
+
+export const GenreTags: React.FC<{ genres?: string[]}> = ({ genres }) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/ItemCardText.tsx b/components/ItemCardText.tsx
index 75b9c232..dd9176b0 100644
--- a/components/ItemCardText.tsx
+++ b/components/ItemCardText.tsx
@@ -8,30 +8,18 @@ type ItemCardProps = {
item: BaseItemDto;
};
-function seasonNameToIndex(seasonName: string | null | undefined) {
- if (!seasonName) return -1;
- if (seasonName.startsWith("Season")) {
- return parseInt(seasonName.replace("Season ", ""));
- }
- if (seasonName.startsWith("Specials")) {
- return 0;
- }
- return -1;
-}
-
export const ItemCardText: React.FC = ({ item }) => {
return (
-
+
{item.Type === "Episode" ? (
<>
-
- {item.SeriesName}
+
+ {item.Name}
- {`S${seasonNameToIndex(
- item?.SeasonName,
- )}:E${item.IndexNumber?.toString()}`}{" "}
- {item.Name}
+ {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
+ {" - "}
+ {item.SeriesName}
>
) : (
diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx
new file mode 100644
index 00000000..42beba5b
--- /dev/null
+++ b/components/ItemContent.tsx
@@ -0,0 +1,294 @@
+import { AudioTrackSelector } from "@/components/AudioTrackSelector";
+import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
+import { DownloadSingleItem } from "@/components/DownloadItem";
+import { OverviewText } from "@/components/OverviewText";
+import { ParallaxScrollView } from "@/components/ParallaxPage";
+import { PlayButton } from "@/components/PlayButton";
+import { PlayedStatus } from "@/components/PlayedStatus";
+import { SimilarItems } from "@/components/SimilarItems";
+import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
+import { ItemImage } from "@/components/common/ItemImage";
+import { CastAndCrew } from "@/components/series/CastAndCrew";
+import { CurrentSeries } from "@/components/series/CurrentSeries";
+import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel";
+import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
+import { useImageColors } from "@/hooks/useImageColors";
+import { useOrientation } from "@/hooks/useOrientation";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+ MediaStream,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { Image } from "expo-image";
+import { useNavigation } from "expo-router";
+import * as ScreenOrientation from "expo-screen-orientation";
+import { useAtom } from "jotai";
+import React, { useEffect, useMemo, useState } from "react";
+import { View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Chromecast } from "./Chromecast";
+import { ItemHeader } from "./ItemHeader";
+import { MediaSourceSelector } from "./MediaSourceSelector";
+import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
+import { SubtitleHelper } from "@/utils/SubtitleHelper";
+import { ItemTechnicalDetails } from "./ItemTechnicalDetails";
+
+export type SelectedOptions = {
+ bitrate: Bitrate;
+ mediaSource: MediaSourceInfo | undefined;
+ audioIndex: number | undefined;
+ subtitleIndex: number;
+};
+
+export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
+ ({ item }) => {
+ const [api] = useAtom(apiAtom);
+ const [settings] = useSettings();
+ const { orientation } = useOrientation();
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ useImageColors({ item });
+
+ const [loadingLogo, setLoadingLogo] = useState(true);
+ const [headerHeight, setHeaderHeight] = useState(350);
+
+ const [selectedOptions, setSelectedOptions] = useState<
+ SelectedOptions | undefined
+ >(undefined);
+
+ const {
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultMediaSource,
+ defaultSubtitleIndex,
+ } = useDefaultPlaySettings(item, settings);
+
+ // Needs to automatically change the selected to the default values for default indexes.
+ useEffect(() => {
+ console.log(defaultAudioIndex, defaultSubtitleIndex);
+ setSelectedOptions(() => ({
+ bitrate: defaultBitrate,
+ mediaSource: defaultMediaSource,
+ subtitleIndex: defaultSubtitleIndex ?? -1,
+ audioIndex: defaultAudioIndex,
+ }));
+ }, [
+ defaultAudioIndex,
+ defaultBitrate,
+ defaultSubtitleIndex,
+ defaultMediaSource,
+ ]);
+
+ useEffect(() => {
+ navigation.setOptions({
+ headerRight: () =>
+ item && (
+
+
+ {item.Type !== "Program" && (
+
+
+
+
+ )}
+
+ ),
+ });
+ }, [item]);
+
+ useEffect(() => {
+ if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
+ setHeaderHeight(230);
+ else if (item.Type === "Movie") setHeaderHeight(500);
+ else setHeaderHeight(350);
+ }, [item.Type, orientation]);
+
+ const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
+
+ const loading = useMemo(() => {
+ return Boolean(logoUrl && loadingLogo);
+ }, [loadingLogo, logoUrl]);
+
+ const [isTranscoding, setIsTranscoding] = useState(false);
+ const [previouslyChosenSubtitleIndex, setPreviouslyChosenSubtitleIndex] =
+ useState(selectedOptions?.subtitleIndex);
+
+ useEffect(() => {
+ const isTranscoding = Boolean(selectedOptions?.bitrate.value);
+ if (isTranscoding) {
+ setPreviouslyChosenSubtitleIndex(selectedOptions?.subtitleIndex);
+ const subHelper = new SubtitleHelper(
+ selectedOptions?.mediaSource?.MediaStreams ?? []
+ );
+
+ const newSubtitleIndex = subHelper.getMostCommonSubtitleByName(
+ selectedOptions?.subtitleIndex
+ );
+
+ setSelectedOptions((prev) => ({
+ ...prev!,
+ subtitleIndex: newSubtitleIndex ?? -1,
+ }));
+ }
+ if (!isTranscoding && previouslyChosenSubtitleIndex !== undefined) {
+ setSelectedOptions((prev) => ({
+ ...prev!,
+ subtitleIndex: previouslyChosenSubtitleIndex,
+ }));
+ }
+ setIsTranscoding(isTranscoding);
+ }, [selectedOptions?.bitrate]);
+
+ if (!selectedOptions) return null;
+
+ return (
+
+
+
+
+ }
+ logo={
+ <>
+ {logoUrl ? (
+ setLoadingLogo(false)}
+ onError={() => setLoadingLogo(false)}
+ />
+ ) : null}
+ >
+ }
+ >
+
+
+
+ {item.Type !== "Program" && (
+
+
+ setSelectedOptions(
+ (prev) => prev && { ...prev, bitrate: val }
+ )
+ }
+ selected={selectedOptions.bitrate}
+ />
+
+ setSelectedOptions(
+ (prev) =>
+ prev && {
+ ...prev,
+ mediaSource: val,
+ }
+ )
+ }
+ selected={selectedOptions.mediaSource}
+ />
+ {
+ console.log(val);
+ setSelectedOptions(
+ (prev) =>
+ prev && {
+ ...prev,
+ audioIndex: val,
+ }
+ );
+ }}
+ selected={selectedOptions.audioIndex}
+ />
+
+ setSelectedOptions(
+ (prev) =>
+ prev && {
+ ...prev,
+ subtitleIndex: val,
+ }
+ )
+ }
+ selected={selectedOptions.subtitleIndex}
+ />
+
+ )}
+
+
+
+
+ {item.Type === "Episode" && (
+
+ )}
+
+
+
+
+ {item.Type !== "Program" && (
+ <>
+ {item.Type === "Episode" && (
+
+ )}
+
+
+
+ {item.People && item.People.length > 0 && (
+
+ {item.People.slice(0, 3).map((person, idx) => (
+
+ ))}
+
+ )}
+
+
+ >
+ )}
+
+
+
+ );
+ }
+);
diff --git a/components/ItemHeader.tsx b/components/ItemHeader.tsx
new file mode 100644
index 00000000..24fb297a
--- /dev/null
+++ b/components/ItemHeader.tsx
@@ -0,0 +1,46 @@
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { View, ViewProps } from "react-native";
+import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
+import { Ratings } from "./Ratings";
+import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
+import { GenreTags } from "./GenreTags";
+import React from "react";
+
+interface Props extends ViewProps {
+ item?: BaseItemDto | null;
+}
+
+export const ItemHeader: React.FC = ({ item, ...props }) => {
+ if (!item)
+ return (
+
+
+
+
+
+
+ );
+
+ return (
+
+
+
+ {item.Type === "Episode" && (
+ <>
+
+
+ >
+ )}
+ {item.Type === "Movie" && (
+ <>
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/components/ItemTechnicalDetails.tsx b/components/ItemTechnicalDetails.tsx
new file mode 100644
index 00000000..82dae2d3
--- /dev/null
+++ b/components/ItemTechnicalDetails.tsx
@@ -0,0 +1,236 @@
+import { Ionicons } from "@expo/vector-icons";
+import {
+ MediaSourceInfo,
+ type MediaStream,
+} from "@jellyfin/sdk/lib/generated-client";
+import React, { useMemo, useRef } from "react";
+import { TouchableOpacity, View } from "react-native";
+import { Badge } from "./Badge";
+import { Text } from "./common/Text";
+import {
+ BottomSheetModal,
+ BottomSheetBackdropProps,
+ BottomSheetBackdrop,
+ BottomSheetView,
+ BottomSheetScrollView,
+} from "@gorhom/bottom-sheet";
+import { Button } from "./Button";
+
+interface Props {
+ source?: MediaSourceInfo;
+}
+
+export const ItemTechnicalDetails: React.FC = ({ source, ...props }) => {
+ const bottomSheetModalRef = useRef(null);
+
+ return (
+
+ Video
+ bottomSheetModalRef.current?.present()}>
+
+
+
+ More details
+
+ (
+
+ )}
+ >
+
+
+
+ Video
+
+
+
+
+
+
+ Audio
+ stream.Type === "Audio"
+ ) || []
+ }
+ />
+
+
+
+ Subtitles
+ stream.Type === "Subtitle"
+ ) || []
+ }
+ />
+
+
+
+
+
+ );
+};
+
+const SubtitleStreamInfo = ({
+ subtitleStreams,
+}: {
+ subtitleStreams: MediaStream[];
+}) => {
+ return (
+
+ {subtitleStreams.map((stream, index) => (
+
+
+ {stream.DisplayTitle}
+
+
+
+ }
+ text={stream.Language}
+ />
+
+ }
+ />
+
+
+ ))}
+
+ );
+};
+
+const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
+ return (
+
+ {audioStreams.map((audioStreams, index) => (
+
+
+ {audioStreams.DisplayTitle}
+
+
+
+ }
+ text={audioStreams.Language}
+ />
+
+ }
+ text={audioStreams.Codec}
+ />
+ }
+ text={audioStreams.ChannelLayout}
+ />
+
+ }
+ text={formatBitrate(audioStreams.BitRate)}
+ />
+
+
+ ))}
+
+ );
+};
+
+const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
+ if (!source) return null;
+
+ const videoStream = useMemo(() => {
+ return source.MediaStreams?.find(
+ (stream) => stream.Type === "Video"
+ ) as MediaStream;
+ }, [source.MediaStreams]);
+
+ return (
+
+ }
+ text={formatFileSize(source.Size)}
+ />
+ }
+ text={`${videoStream.Width}x${videoStream.Height}`}
+ />
+
+ }
+ text={videoStream.VideoRange}
+ />
+
+ }
+ text={videoStream.Codec}
+ />
+
+ }
+ text={formatBitrate(videoStream.BitRate)}
+ />
+ }
+ text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
+ />
+
+ );
+};
+
+const formatFileSize = (bytes?: number | null) => {
+ if (!bytes) return "N/A";
+
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
+ if (bytes === 0) return "0 Byte";
+ const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
+ return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + " " + sizes[i];
+};
+
+const formatBitrate = (bitrate?: number | null) => {
+ if (!bitrate) return "N/A";
+
+ const sizes = ["bps", "Kbps", "Mbps", "Gbps", "Tbps"];
+ if (bitrate === 0) return "0 bps";
+ const i = parseInt(Math.floor(Math.log(bitrate) / Math.log(1000)).toString());
+ return Math.round((bitrate / Math.pow(1000, i)) * 100) / 100 + " " + sizes[i];
+};
diff --git a/components/ListItem.tsx b/components/ListItem.tsx
index b7b4dd9c..755f79ed 100644
--- a/components/ListItem.tsx
+++ b/components/ListItem.tsx
@@ -21,9 +21,13 @@ export const ListItem: React.FC> = ({
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
-
+
{title}
- {subTitle && {subTitle} }
+ {subTitle && (
+
+ {subTitle}
+
+ )}
{iconAfter}
diff --git a/components/MediaSourceSelector.tsx b/components/MediaSourceSelector.tsx
new file mode 100644
index 00000000..34f02fd9
--- /dev/null
+++ b/components/MediaSourceSelector.tsx
@@ -0,0 +1,82 @@
+import { tc } from "@/utils/textTools";
+import {
+ BaseItemDto,
+ MediaSourceInfo,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { useEffect, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import * as DropdownMenu from "zeego/dropdown-menu";
+import { Text } from "./common/Text";
+import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
+
+interface Props extends React.ComponentProps {
+ item: BaseItemDto;
+ onChange: (value: MediaSourceInfo) => void;
+ selected?: MediaSourceInfo | null;
+}
+
+export const MediaSourceSelector: React.FC = ({
+ item,
+ onChange,
+ selected,
+ ...props
+}) => {
+ const selectedName = useMemo(
+ () =>
+ item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
+ (x) => x.Type === "Video"
+ )?.DisplayTitle || "",
+ [item, selected]
+ );
+
+ return (
+
+
+
+
+ Video
+
+ {selectedName}
+
+
+
+
+ Media sources
+ {item.MediaSources?.map((source, idx: number) => (
+ {
+ onChange(source);
+ }}
+ >
+
+ {`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
+ source.Size
+ )}`}
+
+
+ ))}
+
+
+
+ );
+};
+
+const name = (name?: string | null) => {
+ if (name && name.length > 40)
+ return name.substring(0, 20) + " [...] " + name.substring(name.length - 20);
+ return name;
+};
diff --git a/components/MoreMoviesWithActor.tsx b/components/MoreMoviesWithActor.tsx
new file mode 100644
index 00000000..9a2a044f
--- /dev/null
+++ b/components/MoreMoviesWithActor.tsx
@@ -0,0 +1,100 @@
+import React from "react";
+import { View, ViewProps } from "react-native";
+import { Text } from "@/components/common/Text";
+import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
+import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
+import MoviePoster from "@/components/posters/MoviePoster";
+import { ItemCardText } from "@/components/ItemCardText";
+import { useAtom } from "jotai";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { useQuery } from "@tanstack/react-query";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+
+interface Props extends ViewProps {
+ actorId: string;
+ currentItem: BaseItemDto;
+}
+
+export const MoreMoviesWithActor: React.FC = ({
+ actorId,
+ currentItem,
+ ...props
+}) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+
+ const { data: actor } = useQuery({
+ queryKey: ["actor", actorId],
+ queryFn: async () => {
+ if (!api || !user?.Id) return null;
+ return await getUserItemData({
+ api,
+ userId: user.Id,
+ itemId: actorId,
+ });
+ },
+ enabled: !!api && !!user?.Id && !!actorId,
+ });
+
+ const { data: items, isLoading } = useQuery({
+ queryKey: ["actor", "movies", actorId, currentItem.Id],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ personIds: [actorId],
+ limit: 20,
+ sortOrder: ["Descending"],
+ includeItemTypes: ["Movie", "Series"],
+ recursive: true,
+ fields: ["ParentId", "PrimaryImageAspectRatio"],
+ sortBy: ["PremiereDate"],
+ collapseBoxSetItems: false,
+ excludeItemIds: [currentItem.SeriesId || "", currentItem.Id || ""],
+ });
+
+ // Remove duplicates based on item ID
+ const uniqueItems =
+ response.data.Items?.reduce((acc, current) => {
+ const x = acc.find((item) => item.Id === current.Id);
+ if (!x) {
+ return acc.concat([current]);
+ } else {
+ return acc;
+ }
+ }, [] as BaseItemDto[]) || [];
+
+ return uniqueItems;
+ },
+ enabled: !!api && !!user?.Id && !!actorId,
+ });
+
+ if (items?.length === 0) return null;
+
+ return (
+
+
+ More with {actor?.Name}
+
+ (
+
+
+
+
+
+
+ )}
+ />
+
+ );
+};
diff --git a/components/OfflineVideoPlayer.tsx b/components/OfflineVideoPlayer.tsx
deleted file mode 100644
index d694dc3a..00000000
--- a/components/OfflineVideoPlayer.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React, { useEffect, useRef } from "react";
-import Video, { VideoRef } from "react-native-video";
-
-type VideoPlayerProps = {
- url: string;
-};
-
-export const OfflineVideoPlayer: React.FC = ({ url }) => {
- const videoRef = useRef(null);
-
- const onError = (error: any) => {
- console.error("Video Error: ", error);
- };
-
- useEffect(() => {
- if (videoRef.current) {
- videoRef.current.resume();
- }
- setTimeout(() => {
- if (videoRef.current) {
- videoRef.current.presentFullscreenPlayer();
- }
- }, 500);
- }, []);
-
- return (
-
- );
-};
diff --git a/components/OverviewText.tsx b/components/OverviewText.tsx
index 8896a994..87c51487 100644
--- a/components/OverviewText.tsx
+++ b/components/OverviewText.tsx
@@ -5,34 +5,37 @@ import { useState } from "react";
interface Props extends ViewProps {
text?: string | null;
+ characterLimit?: number;
}
-const LIMIT = 140;
-
-export const OverviewText: React.FC = ({ text, ...props }) => {
- const [limit, setLimit] = useState(LIMIT);
+export const OverviewText: React.FC = ({
+ text,
+ characterLimit = 100,
+ ...props
+}) => {
+ const [limit, setLimit] = useState(characterLimit);
if (!text) return null;
- if (text.length > LIMIT)
- return (
+ return (
+
+ Overview
- setLimit((prev) => (prev === LIMIT ? text.length : LIMIT))
+ setLimit((prev) =>
+ prev === characterLimit ? text.length : characterLimit
+ )
}
>
-
+
{tc(text, limit)}
-
- {limit === LIMIT ? "Show more" : "Show less"}
-
+ {text.length > characterLimit && (
+
+ {limit === characterLimit ? "Show more" : "Show less"}
+
+ )}
- );
-
- return (
-
- {text}
);
};
diff --git a/components/ParallaxPage.tsx b/components/ParallaxPage.tsx
index d4aa87ec..daebca6b 100644
--- a/components/ParallaxPage.tsx
+++ b/components/ParallaxPage.tsx
@@ -1,27 +1,27 @@
-import { Ionicons } from "@expo/vector-icons";
-import { router } from "expo-router";
-import type { PropsWithChildren, ReactElement } from "react";
-import { TouchableOpacity, View } from "react-native";
+import { LinearGradient } from "expo-linear-gradient";
+import { type PropsWithChildren, type ReactElement } from "react";
+import { View, ViewProps } from "react-native";
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from "react-native-reanimated";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { Chromecast } from "./Chromecast";
-const HEADER_HEIGHT = 400;
-
-type Props = PropsWithChildren<{
+interface Props extends ViewProps {
headerImage: ReactElement;
logo?: ReactElement;
-}>;
+ episodePoster?: ReactElement;
+ headerHeight?: number;
+}
-export const ParallaxScrollView: React.FC = ({
+export const ParallaxScrollView: React.FC> = ({
children,
headerImage,
+ episodePoster,
+ headerHeight = 400,
logo,
+ ...props
}: Props) => {
const scrollRef = useAnimatedRef();
const scrollOffset = useScrollViewOffset(scrollRef);
@@ -32,25 +32,23 @@ export const ParallaxScrollView: React.FC = ({
{
translateY: interpolate(
scrollOffset.value,
- [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
- [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
+ [-headerHeight, 0, headerHeight],
+ [-headerHeight / 2, 0, headerHeight * 0.75]
),
},
{
scale: interpolate(
scrollOffset.value,
- [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
- [2, 1, 1],
+ [-headerHeight, 0, headerHeight],
+ [2, 1, 1]
),
},
],
};
});
- const inset = useSafeAreaInsets();
-
return (
-
+
= ({
ref={scrollRef}
scrollEventThrottle={16}
>
- router.back()}
- className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
- style={{
- top: inset.top + 17,
- }}
- >
-
-
-
-
-
-
-
{logo && (
-
+
{logo}
)}
@@ -91,7 +71,7 @@ export const ParallaxScrollView: React.FC = ({
= ({
>
{headerImage}
-
+
+
+
+
{children}
diff --git a/components/PlatformBlurView.tsx b/components/PlatformBlurView.tsx
new file mode 100644
index 00000000..77ff0420
--- /dev/null
+++ b/components/PlatformBlurView.tsx
@@ -0,0 +1,35 @@
+import { BlurView } from "expo-blur";
+import React from "react";
+import { Platform, View, ViewProps } from "react-native";
+interface Props extends ViewProps {
+ blurAmount?: number;
+ blurType?: "light" | "dark" | "xlight";
+}
+
+/**
+ * BlurView for iOS and simple View for Android
+ */
+export const PlatformBlurView: React.FC = ({
+ blurAmount = 100,
+ blurType = "light",
+ style,
+ children,
+ ...props
+}) => {
+ if (Platform.OS === "ios") {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx
index 316b3745..e5c5dd87 100644
--- a/components/PlayButton.tsx
+++ b/components/PlayButton.tsx
@@ -1,65 +1,389 @@
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
+import { useSettings } from "@/utils/atoms/settings";
+import { getParentBackdropImageUrl } from "@/utils/jellyfin/image/getParentBackdropImageUrl";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
+import ios from "@/utils/profiles/ios";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
-import { Feather, Ionicons } from "@expo/vector-icons";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { View } from "react-native";
+import { Feather, Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { useRouter } from "expo-router";
+import { useAtom, useAtomValue } from "jotai";
+import { useCallback, useEffect } from "react";
+import { Alert, TouchableOpacity, View } from "react-native";
+import CastContext, {
+ CastButton,
+ PlayServicesState,
+ useMediaStatus,
+ useRemoteMediaClient,
+} from "react-native-google-cast";
+import Animated, {
+ Easing,
+ interpolate,
+ interpolateColor,
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
import { Button } from "./Button";
+import { SelectedOptions } from "./ItemContent";
+import { chromecastProfile } from "@/utils/profiles/chromecast";
+import * as Haptics from "expo-haptics";
interface Props extends React.ComponentProps {
item: BaseItemDto;
- onPress: (type?: "cast" | "device") => void;
- chromecastReady: boolean;
+ selectedOptions: SelectedOptions;
}
+const ANIMATION_DURATION = 500;
+const MIN_PLAYBACK_WIDTH = 15;
+
export const PlayButton: React.FC = ({
item,
- onPress,
- chromecastReady,
+ selectedOptions,
...props
-}) => {
+}: Props) => {
const { showActionSheetWithOptions } = useActionSheet();
+ const client = useRemoteMediaClient();
+ const mediaStatus = useMediaStatus();
- const _onPress = () => {
- if (!chromecastReady) {
- onPress("device");
+ const [colorAtom] = useAtom(itemThemeColorAtom);
+ const api = useAtomValue(apiAtom);
+ const user = useAtomValue(userAtom);
+
+ const router = useRouter();
+
+ const startWidth = useSharedValue(0);
+ const targetWidth = useSharedValue(0);
+ const endColor = useSharedValue(colorAtom);
+ const startColor = useSharedValue(colorAtom);
+ const widthProgress = useSharedValue(0);
+ const colorChangeProgress = useSharedValue(0);
+ const [settings] = useSettings();
+
+ const goToPlayer = useCallback(
+ (q: string, bitrateValue: number | undefined) => {
+ if (!bitrateValue) {
+ router.push(`/player/direct-player?${q}`);
+ return;
+ }
+ router.push(`/player/transcoding-player?${q}`);
+ },
+ [router]
+ );
+
+ const onPress = useCallback(async () => {
+ if (!item) return;
+
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+
+ const queryParams = new URLSearchParams({
+ itemId: item.Id!,
+ audioIndex: selectedOptions.audioIndex?.toString() ?? "",
+ subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
+ mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
+ bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
+ });
+
+ const queryString = queryParams.toString();
+
+ if (!client) {
+ goToPlayer(queryString, selectedOptions.bitrate?.value);
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
-
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
- (selectedIndex: number | undefined) => {
+ async (selectedIndex: number | undefined) => {
+ if (!api) return;
+ const currentTitle = mediaStatus?.mediaInfo?.metadata?.title;
+ const isOpeningCurrentlyPlayingMedia =
+ currentTitle && currentTitle === item?.Name;
+
switch (selectedIndex) {
case 0:
- onPress("cast");
+ await CastContext.getPlayServicesState().then(async (state) => {
+ if (state && state !== PlayServicesState.SUCCESS)
+ CastContext.showPlayServicesErrorDialog(state);
+ else {
+ // Get a new URL with the Chromecast device profile:
+ const data = await getStreamUrl({
+ api,
+ item,
+ deviceProfile: chromecastProfile,
+ startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
+ userId: user?.Id,
+ audioStreamIndex: selectedOptions.audioIndex,
+ maxStreamingBitrate: selectedOptions.bitrate?.value,
+ mediaSourceId: selectedOptions.mediaSource?.Id,
+ subtitleStreamIndex: selectedOptions.subtitleIndex,
+ });
+
+ if (!data?.url) {
+ console.warn("No URL returned from getStreamUrl", data);
+ Alert.alert(
+ "Client error",
+ "Could not create stream for Chromecast"
+ );
+ return;
+ }
+
+ client
+ .loadMedia({
+ mediaInfo: {
+ contentUrl: data?.url,
+ contentType: "video/mp4",
+ metadata:
+ item.Type === "Episode"
+ ? {
+ type: "tvShow",
+ title: item.Name || "",
+ episodeNumber: item.IndexNumber || 0,
+ seasonNumber: item.ParentIndexNumber || 0,
+ seriesTitle: item.SeriesName || "",
+ images: [
+ {
+ url: getParentBackdropImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 2000,
+ })!,
+ },
+ ],
+ }
+ : item.Type === "Movie"
+ ? {
+ type: "movie",
+ title: item.Name || "",
+ subtitle: item.Overview || "",
+ images: [
+ {
+ url: getPrimaryImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 2000,
+ })!,
+ },
+ ],
+ }
+ : {
+ type: "generic",
+ title: item.Name || "",
+ subtitle: item.Overview || "",
+ images: [
+ {
+ url: getPrimaryImageUrl({
+ api,
+ item,
+ quality: 90,
+ width: 2000,
+ })!,
+ },
+ ],
+ },
+ },
+ startTime: 0,
+ })
+ .then(() => {
+ // state is already set when reopening current media, so skip it here.
+ if (isOpeningCurrentlyPlayingMedia) {
+ return;
+ }
+ CastContext.showExpandedControls();
+ });
+ }
+ });
break;
case 1:
- onPress("device");
+ goToPlayer(queryString, selectedOptions.bitrate?.value);
break;
case cancelButtonIndex:
break;
}
- },
+ }
);
- };
+ }, [
+ item,
+ client,
+ settings,
+ api,
+ user,
+ router,
+ showActionSheetWithOptions,
+ mediaStatus,
+ selectedOptions,
+ ]);
+
+ const derivedTargetWidth = useDerivedValue(() => {
+ if (!item || !item.RunTimeTicks) return 0;
+ const userData = item.UserData;
+ if (userData && userData.PlaybackPositionTicks) {
+ return userData.PlaybackPositionTicks > 0
+ ? Math.max(
+ (userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
+ MIN_PLAYBACK_WIDTH
+ )
+ : 0;
+ }
+ return 0;
+ }, [item]);
+
+ useAnimatedReaction(
+ () => derivedTargetWidth.value,
+ (newWidth) => {
+ targetWidth.value = newWidth;
+ widthProgress.value = 0;
+ widthProgress.value = withTiming(1, {
+ duration: ANIMATION_DURATION,
+ easing: Easing.bezier(0.7, 0, 0.3, 1.0),
+ });
+ },
+ [item]
+ );
+
+ useAnimatedReaction(
+ () => colorAtom,
+ (newColor) => {
+ endColor.value = newColor;
+ colorChangeProgress.value = 0;
+ colorChangeProgress.value = withTiming(1, {
+ duration: ANIMATION_DURATION,
+ easing: Easing.bezier(0.9, 0, 0.31, 0.99),
+ });
+ },
+ [colorAtom]
+ );
+
+ useEffect(() => {
+ const timeout_2 = setTimeout(() => {
+ startColor.value = colorAtom;
+ startWidth.value = targetWidth.value;
+ }, ANIMATION_DURATION);
+
+ return () => {
+ clearTimeout(timeout_2);
+ };
+ }, [colorAtom, item]);
+
+ /**
+ * ANIMATED STYLES
+ */
+ const animatedAverageStyle = useAnimatedStyle(() => ({
+ backgroundColor: interpolateColor(
+ colorChangeProgress.value,
+ [0, 1],
+ [startColor.value.primary, endColor.value.primary]
+ ),
+ }));
+
+ const animatedPrimaryStyle = useAnimatedStyle(() => ({
+ backgroundColor: interpolateColor(
+ colorChangeProgress.value,
+ [0, 1],
+ [startColor.value.primary, endColor.value.primary]
+ ),
+ }));
+
+ const animatedWidthStyle = useAnimatedStyle(() => ({
+ width: `${interpolate(
+ widthProgress.value,
+ [0, 1],
+ [startWidth.value, targetWidth.value]
+ )}%`,
+ }));
+
+ const animatedTextStyle = useAnimatedStyle(() => ({
+ color: interpolateColor(
+ colorChangeProgress.value,
+ [0, 1],
+ [startColor.value.text, endColor.value.text]
+ ),
+ }));
+ /**
+ * *********************
+ */
return (
-
-
- {chromecastReady && }
+
+
+
+
- }
- {...props}
- >
- {runtimeTicksToMinutes(item?.RunTimeTicks)}
-
+
+
+
+
+
+ {runtimeTicksToMinutes(item?.RunTimeTicks)}
+
+
+
+
+ {client && (
+
+
+
+
+ )}
+ {!client && settings?.openInVLC && (
+
+
+
+ )}
+
+
+
+ {/*
+
+
+ {directStream ? "Direct stream" : "Transcoded stream"}
+
+ */}
+
);
};
diff --git a/components/PlayedStatus.tsx b/components/PlayedStatus.tsx
index 1cb65034..15890a9f 100644
--- a/components/PlayedStatus.tsx
+++ b/components/PlayedStatus.tsx
@@ -1,27 +1,30 @@
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { markAsNotPlayed } from "@/utils/jellyfin/playstate/markAsNotPlayed";
-import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
-import { Ionicons } from "@expo/vector-icons";
+import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
-import * as Haptics from "expo-haptics";
-import { useAtom } from "jotai";
import React from "react";
-import { TouchableOpacity, View } from "react-native";
+import { View, ViewProps } from "react-native";
+import { RoundButton } from "./RoundButton";
-export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
+interface Props extends ViewProps {
+ item: BaseItemDto;
+}
+export const PlayedStatus: React.FC = ({ item, ...props }) => {
const queryClient = useQueryClient();
const invalidateQueries = () => {
queryClient.invalidateQueries({
- queryKey: ["item"],
+ queryKey: ["item", item.Id],
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
+ queryClient.invalidateQueries({
+ queryKey: ["continueWatching"],
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["nextUp-all"],
+ });
queryClient.invalidateQueries({
queryKey: ["nextUp"],
});
@@ -32,45 +35,20 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
queryKey: ["seasons"],
});
queryClient.invalidateQueries({
- queryKey: ["nextUp-all"],
+ queryKey: ["home"],
});
};
+ const markAsPlayedStatus = useMarkAsPlayed(item);
+
return (
-
- {item.UserData?.Played ? (
- {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- await markAsNotPlayed({
- api: api,
- itemId: item?.Id,
- userId: user?.Id,
- });
- invalidateQueries();
- }}
- >
-
-
-
-
- ) : (
- {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
- await markAsPlayed({
- api: api,
- item: item,
- userId: user?.Id,
- });
- invalidateQueries();
- }}
- >
-
-
-
-
- )}
+
+ markAsPlayedStatus(item.UserData?.Played || false)}
+ size="large"
+ />
);
};
diff --git a/components/Ratings.tsx b/components/Ratings.tsx
index f3d73168..e5eb8fc3 100644
--- a/components/Ratings.tsx
+++ b/components/Ratings.tsx
@@ -3,14 +3,19 @@ import { View, ViewProps } from "react-native";
import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
+import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
+import {useJellyseerr} from "@/hooks/useJellyseerr";
+import {useQuery} from "@tanstack/react-query";
+import {MediaType} from "@/utils/jellyseerr/server/constants/media";
interface Props extends ViewProps {
- item: BaseItemDto;
+ item?: BaseItemDto | null;
}
-export const Ratings: React.FC = ({ item }) => {
+export const Ratings: React.FC = ({ item, ...props }) => {
+ if (!item) return null;
return (
-
+
{item.OfficialRating && (
)}
@@ -39,3 +44,82 @@ export const Ratings: React.FC = ({ item }) => {
);
};
+
+
+export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ result }) => {
+ const {jellyseerrApi} = useJellyseerr();
+ const { data, isLoading } = useQuery({
+ queryKey: ['jellyseerr', result.id, result.mediaType, 'ratings'],
+ queryFn: async () => {
+ return result.mediaType === MediaType.MOVIE
+ ? jellyseerrApi?.movieRatings(result.id)
+ : jellyseerrApi?.tvRatings(result.id)
+ },
+ staleTime: (5).minutesToMilliseconds(),
+ retry: false,
+ enabled: !!jellyseerrApi,
+ });
+
+ return (isLoading || !!result.voteCount ||
+ (data?.criticsRating && !!data?.criticsScore) ||
+ (data?.audienceRating && !!data?.audienceScore)) && (
+
+ {data?.criticsRating && !!data?.criticsScore && (
+
+ }
+ />
+ )}
+ {data?.audienceRating && !!data?.audienceScore && (
+
+ }
+ />
+ )}
+ {!!result.voteCount && (
+
+ }
+ />
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/components/RoundButton.tsx b/components/RoundButton.tsx
new file mode 100644
index 00000000..6feafae5
--- /dev/null
+++ b/components/RoundButton.tsx
@@ -0,0 +1,114 @@
+import { Ionicons } from "@expo/vector-icons";
+import { BlurView } from "expo-blur";
+import { PropsWithChildren } from "react";
+import {
+ Platform,
+ TouchableOpacity,
+ TouchableOpacityProps,
+} from "react-native";
+import * as Haptics from "expo-haptics";
+
+interface Props extends TouchableOpacityProps {
+ onPress?: () => void,
+ icon?: keyof typeof Ionicons.glyphMap;
+ background?: boolean;
+ size?: "default" | "large";
+ fillColor?: "primary";
+ hapticFeedback?: boolean;
+}
+
+export const RoundButton: React.FC> = ({
+ background = true,
+ icon,
+ onPress,
+ children,
+ size = "default",
+ fillColor,
+ hapticFeedback = true,
+ ...props
+}) => {
+ const buttonSize = size === "large" ? "h-10 w-10" : "h-9 w-9";
+ const fillColorClass = fillColor === "primary" ? "bg-purple-600" : "";
+
+ const handlePress = () => {
+ if (hapticFeedback) {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+ onPress?.();
+ };
+
+ if (fillColor)
+ return (
+
+ {icon ? (
+
+ ) : null}
+ {children ? children : null}
+
+ );
+
+ if (background === false)
+ return (
+
+ {icon ? (
+
+ ) : null}
+ {children ? children : null}
+
+ );
+
+ if (Platform.OS === "android")
+ return (
+
+ {icon ? (
+
+ ) : null}
+ {children ? children : null}
+
+ );
+
+ return (
+
+
+ {icon ? (
+
+ ) : null}
+ {children ? children : null}
+
+
+ );
+};
diff --git a/components/SimilarItems.tsx b/components/SimilarItems.tsx
index 45624a52..46815b6d 100644
--- a/components/SimilarItems.tsx
+++ b/components/SimilarItems.tsx
@@ -6,23 +6,28 @@ import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useMemo } from "react";
-import { ScrollView, TouchableOpacity, View } from "react-native";
+import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "./common/Text";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
+import { HorizontalScroll } from "./common/HorrizontalScroll";
+import { TouchableItemRouter } from "./common/TouchableItemRouter";
-type SimilarItemsProps = {
- itemId: string;
-};
+interface SimilarItemsProps extends ViewProps {
+ itemId?: string | null;
+}
-export const SimilarItems: React.FC = ({ itemId }) => {
+export const SimilarItems: React.FC = ({
+ itemId,
+ ...props
+}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: similarItems, isLoading } = useQuery({
queryKey: ["similarItems", itemId],
queryFn: async () => {
- if (!api || !user?.Id) return [];
+ if (!api || !user?.Id || !itemId) return [];
const response = await getLibraryApi(api).getSimilarItems({
itemId,
userId: user.Id,
@@ -41,29 +46,26 @@ export const SimilarItems: React.FC = ({ itemId }) => {
);
return (
-
- Similar items
- {isLoading ? (
-
-
-
- ) : (
-
-
- {movies.map((item) => (
- router.push(`/items/${item.Id}`)}
- className="flex flex-col w-32"
- >
-
-
-
- ))}
-
-
- )}
- {movies.length === 0 && No similar items }
+
+ Similar items
+ (
+
+
+
+
+
+
+ )}
+ />
);
};
diff --git a/components/SubtitleTrackSelector.tsx b/components/SubtitleTrackSelector.tsx
index 349feb3d..087363a3 100644
--- a/components/SubtitleTrackSelector.tsx
+++ b/components/SubtitleTrackSelector.tsx
@@ -1,63 +1,61 @@
-import { TouchableOpacity, View } from "react-native";
+import { tc } from "@/utils/textTools";
+import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
+import { useMemo } from "react";
+import { Platform, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
-import { atom, useAtom } from "jotai";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useEffect, useMemo } from "react";
-import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
-import { tc } from "@/utils/textTools";
+import { SubtitleHelper } from "@/utils/SubtitleHelper";
interface Props extends React.ComponentProps {
- item: BaseItemDto;
+ source?: MediaSourceInfo;
onChange: (value: number) => void;
- selected: number;
+ selected?: number | undefined;
+ isTranscoding?: boolean;
}
export const SubtitleTrackSelector: React.FC = ({
- item,
+ source,
onChange,
selected,
+ isTranscoding,
...props
}) => {
- const subtitleStreams = useMemo(
- () =>
- item.MediaSources?.[0].MediaStreams?.filter(
- (x) => x.Type === "Subtitle",
- ) ?? [],
- [item],
- );
+ const subtitleStreams = useMemo(() => {
+ const subtitleHelper = new SubtitleHelper(source?.MediaStreams ?? []);
+
+ if (isTranscoding && Platform.OS === "ios") {
+ return subtitleHelper.getUniqueSubtitles();
+ }
+
+ return subtitleHelper.getSubtitles();
+ }, [source, isTranscoding]);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
- [subtitleStreams, selected],
+ [subtitleStreams, selected]
);
- useEffect(() => {
- const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex;
- if (index !== undefined && index !== null) {
- onChange(index);
- } else {
- onChange(-1);
- }
- }, []);
-
if (subtitleStreams.length === 0) return null;
return (
-
+
-
- Subtitles
-
-
-
- {selectedSubtitleSteam
- ? tc(selectedSubtitleSteam?.DisplayTitle, 13)
- : "None"}
-
-
-
+
+ Subtitle
+
+
+ {selectedSubtitleSteam
+ ? tc(selectedSubtitleSteam?.DisplayTitle, 7)
+ : "None"}
+
+
= ({
collisionPadding={8}
sideOffset={8}
>
- Subtitles
+ Subtitle tracks
{
diff --git a/components/WatchedIndicator.tsx b/components/WatchedIndicator.tsx
index 1ed5d0e4..d445c021 100644
--- a/components/WatchedIndicator.tsx
+++ b/components/WatchedIndicator.tsx
@@ -1,4 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import React from "react";
import { View } from "react-native";
export const WatchedIndicator: React.FC<{ item: BaseItemDto }> = ({ item }) => {
diff --git a/components/common/HeaderBackButton.tsx b/components/common/HeaderBackButton.tsx
new file mode 100644
index 00000000..12d8071e
--- /dev/null
+++ b/components/common/HeaderBackButton.tsx
@@ -0,0 +1,60 @@
+import {
+ Platform,
+ TouchableOpacity,
+ TouchableOpacityProps,
+ View,
+ ViewProps,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import { useRouter } from "expo-router";
+import { Ionicons } from "@expo/vector-icons";
+import { BlurView, BlurViewProps } from "expo-blur";
+
+interface Props extends BlurViewProps {
+ background?: "blur" | "transparent";
+ touchableOpacityProps?: TouchableOpacityProps;
+}
+
+export const HeaderBackButton: React.FC = ({
+ background = "transparent",
+ touchableOpacityProps,
+ ...props
+}) => {
+ const router = useRouter();
+
+ if (background === "transparent" && Platform.OS !== "android")
+ return (
+ router.back()}
+ {...touchableOpacityProps}
+ >
+
+
+
+
+ );
+
+ return (
+ router.back()}
+ className=" bg-neutral-800/80 rounded-full p-2"
+ {...touchableOpacityProps}
+ >
+
+
+ );
+};
diff --git a/components/common/HorrizontalScroll.tsx b/components/common/HorrizontalScroll.tsx
index b5b78c6a..2dce75d4 100644
--- a/components/common/HorrizontalScroll.tsx
+++ b/components/common/HorrizontalScroll.tsx
@@ -1,88 +1,108 @@
-import React, { useEffect } from "react";
-import { ScrollView, ScrollViewProps, View, ViewStyle } from "react-native";
-import Animated, {
- useAnimatedStyle,
- useSharedValue,
- withTiming,
-} from "react-native-reanimated";
-import { Loader } from "../Loader";
+import { FlashList, FlashListProps } from "@shopify/flash-list";
+import React, { forwardRef, useImperativeHandle, useRef } from "react";
+import { View, ViewStyle } from "react-native";
import { Text } from "./Text";
-interface HorizontalScrollProps extends ScrollViewProps {
+type PartialExcept = Partial & Pick;
+
+export interface HorizontalScrollRef {
+ scrollToIndex: (index: number, viewOffset: number) => void;
+}
+
+interface HorizontalScrollProps
+ extends PartialExcept<
+ Omit, "renderItem">,
+ "estimatedItemSize"
+ > {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
+ keyExtractor?: (item: T, index: number) => string;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number;
loading?: boolean;
+ extraData?: any;
+ noItemsText?: string;
}
-export function HorizontalScroll({
- data = [],
- renderItem,
- containerStyle,
- contentContainerStyle,
- loadingContainerStyle,
- loading = false,
- height = 164,
- ...props
-}: HorizontalScrollProps): React.ReactElement {
- const animatedOpacity = useSharedValue(0);
- const animatedStyle1 = useAnimatedStyle(() => {
- return {
- opacity: withTiming(animatedOpacity.value, { duration: 250 }),
- };
- });
+export const HorizontalScroll = forwardRef<
+ HorizontalScrollRef,
+ HorizontalScrollProps
+>(
+ (
+ {
+ data = [],
+ keyExtractor,
+ renderItem,
+ containerStyle,
+ contentContainerStyle,
+ loadingContainerStyle,
+ loading = false,
+ height = 164,
+ extraData,
+ noItemsText,
+ ...props
+ }: HorizontalScrollProps,
+ ref: React.ForwardedRef
+ ) => {
+ const flashListRef = useRef>(null);
- useEffect(() => {
- if (data) {
- animatedOpacity.value = 1;
- }
- }, [data]);
+ useImperativeHandle(ref!, () => ({
+ scrollToIndex: (index: number, viewOffset: number) => {
+ flashListRef.current?.scrollToIndex({
+ index,
+ animated: true,
+ viewPosition: 0,
+ viewOffset,
+ });
+ },
+ }));
- if (data === undefined || data === null || loading) {
- return (
-
-
+ const renderFlashListItem = ({
+ item,
+ index,
+ }: {
+ item: T;
+ index: number;
+ }) => (
+
+ {renderItem(item, index)}
);
- }
- return (
-
-
- {data.map((item, index) => (
-
- {renderItem(item, index)}
-
- ))}
- {data.length === 0 && (
+ if (!data || loading) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+ ref={flashListRef}
+ data={data}
+ extraData={extraData}
+ renderItem={renderFlashListItem}
+ horizontal
+ estimatedItemSize={200}
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={{
+ paddingHorizontal: 16,
+ ...contentContainerStyle,
+ }}
+ keyExtractor={keyExtractor}
+ ListEmptyComponent={() => (
- No data available
+
+ {noItemsText || "No data available"}
+
)}
-
-
- );
-}
+ {...props}
+ />
+ );
+ }
+);
diff --git a/components/common/InfiniteHorrizontalScroll.tsx b/components/common/InfiniteHorrizontalScroll.tsx
index 9402feff..e8281d0e 100644
--- a/components/common/InfiniteHorrizontalScroll.tsx
+++ b/components/common/InfiniteHorrizontalScroll.tsx
@@ -1,11 +1,13 @@
-import React, { useEffect } from "react";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
- NativeScrollEvent,
- ScrollView,
- ScrollViewProps,
- View,
- ViewStyle,
-} from "react-native";
+ BaseItemDto,
+ BaseItemDtoQueryResult,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { FlashList, FlashListProps } from "@shopify/flash-list";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useAtom } from "jotai";
+import React, { useEffect, useMemo } from "react";
+import { View, ViewStyle } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -13,16 +15,9 @@ import Animated, {
} from "react-native-reanimated";
import { Loader } from "../Loader";
import { Text } from "./Text";
-import { useInfiniteQuery } from "@tanstack/react-query";
-import {
- BaseItemDto,
- BaseItemDtoQueryResult,
-} from "@jellyfin/sdk/lib/generated-client/models";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { useNavigation } from "expo-router";
-import { useAtom } from "jotai";
-interface HorizontalScrollProps extends ScrollViewProps {
+interface HorizontalScrollProps
+ extends Omit, "renderItem" | "data" | "style"> {
queryFn: ({
pageParam,
}: {
@@ -38,18 +33,6 @@ interface HorizontalScrollProps extends ScrollViewProps {
loading?: boolean;
}
-const isCloseToBottom = ({
- layoutMeasurement,
- contentOffset,
- contentSize,
-}: NativeScrollEvent) => {
- const paddingToBottom = 50;
- return (
- layoutMeasurement.height + contentOffset.y >=
- contentSize.height - paddingToBottom
- );
-};
-
export function InfiniteHorizontalScroll({
queryFn,
queryKey,
@@ -64,7 +47,6 @@ export function InfiniteHorizontalScroll({
}: HorizontalScrollProps): React.ReactElement {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
- const navigation = useNavigation();
const animatedOpacity = useSharedValue(0);
const animatedStyle1 = useAnimatedStyle(() => {
@@ -73,7 +55,7 @@ export function InfiniteHorizontalScroll({
};
});
- const { data, isFetching, fetchNextPage } = useInfiniteQuery({
+ const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey,
queryFn,
getNextPageParam: (lastPage, pages) => {
@@ -100,6 +82,13 @@ export function InfiniteHorizontalScroll({
enabled: !!api && !!user?.Id,
});
+ const flatData = useMemo(() => {
+ return (
+ (data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
+ []
+ );
+ }, [data]);
+
useEffect(() => {
if (data) {
animatedOpacity.value = 1;
@@ -124,41 +113,34 @@ export function InfiniteHorizontalScroll({
}
return (
- {
- if (isCloseToBottom(nativeEvent)) {
- fetchNextPage();
- }
- }}
- scrollEventThrottle={400}
- style={containerStyle}
- contentContainerStyle={contentContainerStyle}
- showsHorizontalScrollIndicator={false}
- {...props}
- >
-
- {data?.pages
- .flatMap((page) => page?.Items)
- .map(
- (item, index) =>
- item && (
-
- {renderItem(item, index)}
-
- )
- )}
- {data?.pages.flatMap((page) => page?.Items).length === 0 && (
+
+ (
+
+ {renderItem(item, index)}
+
+ )}
+ estimatedItemSize={height}
+ horizontal
+ onEndReached={() => {
+ if (hasNextPage) {
+ fetchNextPage();
+ }
+ }}
+ onEndReachedThreshold={0.5}
+ contentContainerStyle={{
+ paddingHorizontal: 16,
+ ...contentContainerStyle,
+ }}
+ showsHorizontalScrollIndicator={false}
+ ListEmptyComponent={
No data available
- )}
-
-
+ }
+ {...props}
+ />
+
);
}
diff --git a/components/common/Input.tsx b/components/common/Input.tsx
index 62f19023..ba4ab45b 100644
--- a/components/common/Input.tsx
+++ b/components/common/Input.tsx
@@ -10,9 +10,9 @@ export function Input(props: TextInputProps) {
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
- {...otherProps}
placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing"
+ {...otherProps}
/>
);
}
diff --git a/components/common/ItemImage.tsx b/components/common/ItemImage.tsx
new file mode 100644
index 00000000..9e38bc06
--- /dev/null
+++ b/components/common/ItemImage.tsx
@@ -0,0 +1,85 @@
+import { useImageColors } from "@/hooks/useImageColors";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getItemImage } from "@/utils/getItemImage";
+import { Ionicons } from "@expo/vector-icons";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { Image, ImageProps, ImageSource } from "expo-image";
+import { useAtom } from "jotai";
+import { useMemo } from "react";
+import { View } from "react-native";
+
+interface Props extends ImageProps {
+ item: BaseItemDto;
+ variant?:
+ | "Primary"
+ | "Backdrop"
+ | "ParentBackdrop"
+ | "ParentLogo"
+ | "Logo"
+ | "AlbumPrimary"
+ | "SeriesPrimary"
+ | "Screenshot"
+ | "Thumb";
+ quality?: number;
+ width?: number;
+ onError?: () => void;
+}
+
+export const ItemImage: React.FC = ({
+ item,
+ variant = "Primary",
+ quality = 90,
+ width = 1000,
+ onError,
+ ...props
+}) => {
+ const [api] = useAtom(apiAtom);
+
+ const source = useMemo(() => {
+ if (!api) {
+ onError && onError();
+ return;
+ }
+ return getItemImage({
+ item,
+ api,
+ variant,
+ quality,
+ width,
+ });
+ }, [api, item, quality, variant, width]);
+
+ // return placeholder icon if no source
+ if (!source?.uri)
+ return (
+
+
+
+ );
+
+ return (
+
+ );
+};
diff --git a/components/common/JellyseerrItemRouter.tsx b/components/common/JellyseerrItemRouter.tsx
new file mode 100644
index 00000000..90f9c336
--- /dev/null
+++ b/components/common/JellyseerrItemRouter.tsx
@@ -0,0 +1,103 @@
+import {useRouter, useSegments} from "expo-router";
+import React, {PropsWithChildren, useCallback, useMemo} from "react";
+import {TouchableOpacity, TouchableOpacityProps} from "react-native";
+import * as ContextMenu from "zeego/context-menu";
+import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
+import {useJellyseerr} from "@/hooks/useJellyseerr";
+import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
+import {MediaType} from "@/utils/jellyseerr/server/constants/media";
+
+interface Props extends TouchableOpacityProps {
+ result: MovieResult | TvResult;
+ mediaTitle: string;
+ releaseYear: number;
+ canRequest: boolean;
+ posterSrc: string;
+}
+
+export const TouchableJellyseerrRouter: React.FC> = ({
+ result,
+ mediaTitle,
+ releaseYear,
+ canRequest,
+ posterSrc,
+ children,
+ ...props
+}) => {
+ const router = useRouter();
+ const segments = useSegments();
+ const {jellyseerrApi, jellyseerrUser, requestMedia} = useJellyseerr()
+
+ const from = segments[2];
+
+ const autoApprove = useMemo(() => {
+ return jellyseerrUser && hasPermission(
+ Permission.AUTO_APPROVE,
+ jellyseerrUser.permissions,
+ {type: 'or'}
+ )
+ }, [jellyseerrApi, jellyseerrUser])
+
+ const request = useCallback(() =>
+ requestMedia(mediaTitle, {
+ mediaId: result.id,
+ mediaType: result.mediaType
+ }
+ ),
+ [jellyseerrApi, result]
+ )
+
+ if (from === "(home)" || from === "(search)" || from === "(libraries)")
+ return (
+ <>
+
+
+ {
+ // @ts-ignore
+ router.push({pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, params: {...result, mediaTitle, releaseYear, canRequest, posterSrc}});
+ }}
+ {...props}
+ >
+ {children}
+
+
+
+ Actions
+ {canRequest && result.mediaType === MediaType.MOVIE && (
+ {
+ if (autoApprove) {
+ request()
+ }
+ }}
+ shouldDismissMenuOnSelect
+ >
+ Request
+
+
+ )}
+
+
+ >
+ );
+};
diff --git a/components/common/Text.tsx b/components/common/Text.tsx
index bdede438..ef7a6491 100644
--- a/components/common/Text.tsx
+++ b/components/common/Text.tsx
@@ -1,11 +1,16 @@
import React from "react";
import { TextProps } from "react-native";
-import { Text as DefaultText } from "react-native";
-export function Text(props: TextProps) {
+import { UITextView } from "react-native-uitextview";
+
+export function Text(
+ props: TextProps & {
+ uiTextView?: boolean;
+ }
+) {
const { style, ...otherProps } = props;
return (
- {
+ if (item.CollectionType === "livetv") {
+ return `/(auth)/(tabs)/${from}/livetv`;
+ }
+
+ if (item.Type === "Series") {
+ return `/(auth)/(tabs)/${from}/series/${item.Id}`;
+ }
+
+ if (item.Type === "MusicAlbum") {
+ return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
+ }
+
+ if (item.Type === "Audio") {
+ return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
+ }
+
+ if (item.Type === "MusicArtist") {
+ return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
+ }
+
+ if (item.Type === "Person") {
+ return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
+ }
+
+ if (item.Type === "BoxSet") {
+ return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
+ }
+
+ if (item.Type === "UserView") {
+ return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
+ }
+
+ if (item.Type === "CollectionFolder") {
+ return `/(auth)/(tabs)/(libraries)/${item.Id}`;
+ }
+
+ if (item.Type === "Playlist") {
+ return `/(auth)/(tabs)/(libraries)/${item.Id}`;
+ }
+
+ return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
+};
+
export const TouchableItemRouter: React.FC> = ({
item,
children,
...props
}) => {
const router = useRouter();
- return (
- {
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ const segments = useSegments();
- if (item.Type === "Series") {
- router.push(`/series/${item.Id}`);
- return;
- }
- if (item.Type === "Episode") {
- router.push(`/items/${item.Id}`);
- return;
- }
- if (item.Type === "MusicAlbum") {
- router.push(`/albums/${item.Id}`);
- return;
- }
- if (item.Type === "Audio") {
- router.push(`/albums/${item.AlbumId}`);
- return;
- }
- if (item.Type === "MusicArtist") {
- router.push(`/artists/${item.Id}/page`);
- return;
- }
+ const from = segments[2];
- // Movies and all other cases
- if (item.Type === "BoxSet") {
- router.push(`/collections/${item.Id}`);
- return;
- }
+ const markAsPlayedStatus = useMarkAsPlayed(item);
- router.push(`/items/${item.Id}`);
- }}
- {...props}
- >
- {children}
-
- );
+ if (from === "(home)" || from === "(search)" || from === "(libraries)")
+ return (
+
+
+ {
+ const url = itemRouter(item, from);
+ // @ts-ignore
+ router.push(url);
+ }}
+ {...props}
+ >
+ {children}
+
+
+
+ Actions
+ {
+ markAsPlayedStatus(true);
+ }}
+ shouldDismissMenuOnSelect
+ >
+
+ Mark as watched
+
+
+
+ {
+ markAsPlayedStatus(false);
+ }}
+ shouldDismissMenuOnSelect
+ destructive
+ >
+
+ Mark as not watched
+
+
+
+
+
+ );
};
diff --git a/components/common/VerticalSkeleton.tsx b/components/common/VerticalSkeleton.tsx
new file mode 100644
index 00000000..1b2b1457
--- /dev/null
+++ b/components/common/VerticalSkeleton.tsx
@@ -0,0 +1,29 @@
+import { View, ViewProps } from "react-native";
+import { Text } from "@/components/common/Text";
+
+interface Props extends ViewProps {
+ index: number;
+}
+
+export const VerticalSkeleton: React.FC = ({ index, ...props }) => {
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/components/downloads/ActiveDownloads.tsx b/components/downloads/ActiveDownloads.tsx
new file mode 100644
index 00000000..556ae8c7
--- /dev/null
+++ b/components/downloads/ActiveDownloads.tsx
@@ -0,0 +1,191 @@
+import { Text } from "@/components/common/Text";
+import { useDownload } from "@/providers/DownloadProvider";
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { JobStatus } from "@/utils/optimize-server";
+import { formatTimeString } from "@/utils/time";
+import { Ionicons } from "@expo/vector-icons";
+import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRouter } from "expo-router";
+import { FFmpegKit } from "ffmpeg-kit-react-native";
+import { useAtom } from "jotai";
+import {
+ ActivityIndicator,
+ TouchableOpacity,
+ TouchableOpacityProps,
+ View,
+ ViewProps,
+} from "react-native";
+import { toast } from "sonner-native";
+import { Button } from "../Button";
+import { Image } from "expo-image";
+import { useMemo } from "react";
+import { storage } from "@/utils/mmkv";
+
+interface Props extends ViewProps {}
+
+export const ActiveDownloads: React.FC = ({ ...props }) => {
+ const { processes } = useDownload();
+ if (processes?.length === 0)
+ return (
+
+ Active download
+ No active downloads
+
+ );
+
+ return (
+
+ Active downloads
+
+ {processes?.map((p) => (
+
+ ))}
+
+
+ );
+};
+
+interface DownloadCardProps extends TouchableOpacityProps {
+ process: JobStatus;
+}
+
+const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
+ const { processes, startDownload } = useDownload();
+ const router = useRouter();
+ const { removeProcess, setProcesses } = useDownload();
+ const [settings] = useSettings();
+ const queryClient = useQueryClient();
+
+ const cancelJobMutation = useMutation({
+ mutationFn: async (id: string) => {
+ if (!process) throw new Error("No active download");
+
+ if (settings?.downloadMethod === "optimized") {
+ try {
+ const tasks = await checkForExistingDownloads();
+ for (const task of tasks) {
+ if (task.id === id) {
+ task.stop();
+ }
+ }
+ } catch (e) {
+ throw e;
+ } finally {
+ await removeProcess(id);
+ await queryClient.refetchQueries({ queryKey: ["jobs"] });
+ }
+ } else {
+ FFmpegKit.cancel(Number(id));
+ setProcesses((prev) => prev.filter((p) => p.id !== id));
+ }
+ },
+ onSuccess: () => {
+ toast.success("Download canceled");
+ },
+ onError: (e) => {
+ console.error(e);
+ toast.error("Could not cancel download");
+ },
+ });
+
+ const eta = (p: JobStatus) => {
+ if (!p.speed || !p.progress) return null;
+
+ const length = p?.item?.RunTimeTicks || 0;
+ const timeLeft = (length - length * (p.progress / 100)) / p.speed;
+ return formatTimeString(timeLeft, "tick");
+ };
+
+ const base64Image = useMemo(() => {
+ return storage.getString(process.item.Id!);
+ }, []);
+
+ return (
+ router.push(`/(auth)/items/page?id=${process.item.Id}`)}
+ className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
+ {...props}
+ >
+ {(process.status === "optimizing" ||
+ process.status === "downloading") && (
+
+ )}
+
+
+ {base64Image && (
+
+
+
+ )}
+
+ {process.item.Type}
+ {process.item.Name}
+
+ {process.item.ProductionYear}
+
+
+ {process.progress === 0 ? (
+
+ ) : (
+ {process.progress.toFixed(0)}%
+ )}
+ {process.speed && (
+ {process.speed?.toFixed(2)}x
+ )}
+ {eta(process) && (
+ ETA {eta(process)}
+ )}
+
+
+
+ {process.status}
+
+
+ cancelJobMutation.mutate(process.id)}
+ className="ml-auto"
+ >
+ {cancelJobMutation.isPending ? (
+
+ ) : (
+
+ )}
+
+
+ {process.status === "completed" && (
+
+ {
+ startDownload(process);
+ }}
+ className="w-full"
+ >
+ Download now
+
+
+ )}
+
+
+ );
+};
diff --git a/components/downloads/DownloadSize.tsx b/components/downloads/DownloadSize.tsx
new file mode 100644
index 00000000..48a52a29
--- /dev/null
+++ b/components/downloads/DownloadSize.tsx
@@ -0,0 +1,47 @@
+import { Text } from "@/components/common/Text";
+import { useDownload } from "@/providers/DownloadProvider";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import React, { useEffect, useMemo, useState } from "react";
+import { TextProps } from "react-native";
+
+interface DownloadSizeProps extends TextProps {
+ items: BaseItemDto[];
+}
+
+export const DownloadSize: React.FC = ({
+ items,
+ ...props
+}) => {
+ const { downloadedFiles, getDownloadedItemSize } = useDownload();
+ const [size, setSize] = useState();
+
+ const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
+
+ useEffect(() => {
+ if (!downloadedFiles) return;
+
+ let s = 0;
+
+ for (const item of items) {
+ if (!item.Id) continue;
+ const size = getDownloadedItemSize(item.Id);
+ if (size) {
+ s += size;
+ }
+ }
+ setSize(s.bytesToReadable());
+ }, [itemIds]);
+
+ const sizeText = useMemo(() => {
+ if (!size) return "...";
+ return size;
+ }, [size]);
+
+ return (
+ <>
+
+ {sizeText}
+
+ >
+ );
+};
diff --git a/components/downloads/EpisodeCard.tsx b/components/downloads/EpisodeCard.tsx
index 68678323..e8387da5 100644
--- a/components/downloads/EpisodeCard.tsx
+++ b/components/downloads/EpisodeCard.tsx
@@ -1,48 +1,39 @@
-import React, { useCallback } from "react";
-import { TouchableOpacity } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import * as ContextMenu from "zeego/context-menu";
import * as Haptics from "expo-haptics";
-import * as FileSystem from "expo-file-system";
-import { useAtom } from "jotai";
-
-import { Text } from "../common/Text";
-import { useFiles } from "@/hooks/useFiles";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import {
- currentlyPlayingItemAtom,
- fullScreenAtom,
- playingAtom,
-} from "../CurrentlyPlayingBar";
-import { useSettings } from "@/utils/atoms/settings";
+ ActionSheetProvider,
+ useActionSheet,
+} from "@expo/react-native-action-sheet";
-interface EpisodeCardProps {
+import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
+import { useDownload } from "@/providers/DownloadProvider";
+import { storage } from "@/utils/mmkv";
+import { Image } from "expo-image";
+import { Ionicons } from "@expo/vector-icons";
+import { Text } from "@/components/common/Text";
+import { runtimeTicksToSeconds } from "@/utils/time";
+import { DownloadSize } from "@/components/downloads/DownloadSize";
+import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import ContinueWatchingPoster from "../ContinueWatchingPoster";
+
+interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto;
}
-/**
- * EpisodeCard component displays an episode with context menu options.
- * @param {EpisodeCardProps} props - The component props.
- * @returns {React.ReactElement} The rendered EpisodeCard component.
- */
-export const EpisodeCard: React.FC = ({ item }) => {
- const { deleteFile } = useFiles();
- const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
- const [, setPlaying] = useAtom(playingAtom);
- const [, setFullscreen] = useAtom(fullScreenAtom);
- const [settings] = useSettings();
+export const EpisodeCard: React.FC = ({ item, ...props }) => {
+ const { deleteFile } = useDownload();
+ const { openFile } = useDownloadedFileOpener();
+ const { showActionSheetWithOptions } = useActionSheet();
- /**
- * Handles opening the file for playback.
- */
- const handleOpenFile = useCallback(async () => {
- setCurrentlyPlaying({
- item,
- playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
- });
- setPlaying(true);
- if (settings?.openFullScreenVideoPlayerByDefault === true)
- setFullscreen(true);
- }, [item, setCurrentlyPlaying, settings]);
+ const base64Image = useMemo(() => {
+ return storage.getString(item.Id!);
+ }, [item]);
+
+ const handleOpenFile = useCallback(() => {
+ openFile(item);
+ }, [item, openFile]);
/**
* Handles deleting the file with haptic feedback.
@@ -54,43 +45,68 @@ export const EpisodeCard: React.FC = ({ item }) => {
}
}, [deleteFile, item.Id]);
- const contextMenuOptions = [
- {
- label: "Delete",
- onSelect: handleDeleteFile,
- destructive: true,
- },
- ];
+ const showActionSheet = useCallback(() => {
+ const options = ["Delete", "Cancel"];
+ const destructiveButtonIndex = 0;
+ const cancelButtonIndex = 1;
+
+ showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex,
+ destructiveButtonIndex,
+ },
+ (selectedIndex) => {
+ switch (selectedIndex) {
+ case destructiveButtonIndex:
+ // Delete
+ handleDeleteFile();
+ break;
+ case cancelButtonIndex:
+ // Cancelled
+ break;
+ }
+ }
+ );
+ }, [showActionSheetWithOptions, handleDeleteFile]);
return (
-
-
-
- {item.Name}
- Episode {item.IndexNumber}
-
-
-
- {contextMenuOptions.map((option) => (
-
-
- {option.label}
-
-
- ))}
-
-
+
+
+
+
+
+
+
+ {item.Name}
+
+
+ {`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}
+
+
+ {runtimeTicksToSeconds(item.RunTimeTicks)}
+
+
+
+
+
+
+ {item.Overview}
+
+
);
};
+
+// Wrap the parent component with ActionSheetProvider
+export const EpisodeCardWithActionSheet: React.FC = (
+ props
+) => (
+
+
+
+);
diff --git a/components/downloads/MovieCard.tsx b/components/downloads/MovieCard.tsx
index 5deb999d..3073bd0a 100644
--- a/components/downloads/MovieCard.tsx
+++ b/components/downloads/MovieCard.tsx
@@ -1,51 +1,41 @@
-import React, { useCallback } from "react";
-import { TouchableOpacity, View } from "react-native";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import * as ContextMenu from "zeego/context-menu";
-import * as FileSystem from "expo-file-system";
-import * as Haptics from "expo-haptics";
-import { useAtom } from "jotai";
-
-import { Text } from "../common/Text";
-import { useFiles } from "@/hooks/useFiles";
-import { runtimeTicksToMinutes } from "@/utils/time";
import {
- currentlyPlayingItemAtom,
- fullScreenAtom,
- playingAtom,
-} from "../CurrentlyPlayingBar";
-import { useSettings } from "@/utils/atoms/settings";
+ ActionSheetProvider,
+ useActionSheet,
+} from "@expo/react-native-action-sheet";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import * as Haptics from "expo-haptics";
+import React, { useCallback, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+
+import { DownloadSize } from "@/components/downloads/DownloadSize";
+import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
+import { useDownload } from "@/providers/DownloadProvider";
+import { storage } from "@/utils/mmkv";
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import { ItemCardText } from "../ItemCardText";
interface MovieCardProps {
item: BaseItemDto;
}
/**
- * MovieCard component displays a movie with context menu options.
+ * MovieCard component displays a movie with action sheet options.
* @param {MovieCardProps} props - The component props.
* @returns {React.ReactElement} The rendered MovieCard component.
*/
export const MovieCard: React.FC = ({ item }) => {
- const { deleteFile } = useFiles();
- const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
- const [, setPlaying] = useAtom(playingAtom);
- const [, setFullscreen] = useAtom(fullScreenAtom);
- const [settings] = useSettings();
+ const { deleteFile } = useDownload();
+ const { openFile } = useDownloadedFileOpener();
+ const { showActionSheetWithOptions } = useActionSheet();
- /**
- * Handles opening the file for playback.
- */
const handleOpenFile = useCallback(() => {
- console.log("Open movie file", item.Name);
- setCurrentlyPlaying({
- item,
- playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
- });
- setPlaying(true);
- if (settings?.openFullScreenVideoPlayerByDefault === true) {
- setFullscreen(true);
- }
- }, [item, setCurrentlyPlaying, setPlaying, settings]);
+ openFile(item);
+ }, [item, openFile]);
+
+ const base64Image = useMemo(() => {
+ return storage.getString(item.Id!);
+ }, []);
/**
* Handles deleting the file with haptic feedback.
@@ -57,43 +47,67 @@ export const MovieCard: React.FC = ({ item }) => {
}
}, [deleteFile, item.Id]);
- const contextMenuOptions = [
- {
- label: "Delete",
- onSelect: handleDeleteFile,
- destructive: true,
- },
- ];
+ const showActionSheet = useCallback(() => {
+ const options = ["Delete", "Cancel"];
+ const destructiveButtonIndex = 0;
+ const cancelButtonIndex = 1;
+
+ showActionSheetWithOptions(
+ {
+ options,
+ cancelButtonIndex,
+ destructiveButtonIndex,
+ },
+ (selectedIndex) => {
+ switch (selectedIndex) {
+ case destructiveButtonIndex:
+ // Delete
+ handleDeleteFile();
+ break;
+ case cancelButtonIndex:
+ // Cancelled
+ break;
+ }
+ }
+ );
+ }, [showActionSheetWithOptions, handleDeleteFile]);
return (
-
-
-
- {item.Name}
-
- {item.ProductionYear}
-
- {runtimeTicksToMinutes(item.RunTimeTicks)}
-
-
-
-
-
- {contextMenuOptions.map((option) => (
-
-
- {option.label}
-
-
- ))}
-
-
+
+ {base64Image ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
);
};
+
+// Wrap the parent component with ActionSheetProvider
+export const MovieCardWithActionSheet: React.FC = (props) => (
+
+
+
+);
diff --git a/components/downloads/SeriesCard.tsx b/components/downloads/SeriesCard.tsx
index 96c63675..4c6efa1f 100644
--- a/components/downloads/SeriesCard.tsx
+++ b/components/downloads/SeriesCard.tsx
@@ -1,49 +1,82 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { View } from "react-native";
-import { EpisodeCard } from "./EpisodeCard";
+import {TouchableOpacity, View} from "react-native";
import { Text } from "../common/Text";
-import { useMemo } from "react";
-import { SeasonPicker } from "../series/SeasonPicker";
+import React, {useCallback, useMemo} from "react";
+import {storage} from "@/utils/mmkv";
+import {Image} from "expo-image";
+import {Ionicons} from "@expo/vector-icons";
+import {router} from "expo-router";
+import {DownloadSize} from "@/components/downloads/DownloadSize";
+import {useDownload} from "@/providers/DownloadProvider";
+import {useActionSheet} from "@expo/react-native-action-sheet";
-export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
- const groupBySeason = useMemo(() => {
- const seasons: Record = {};
+export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({items}) => {
+ const { deleteItems } = useDownload();
+ const { showActionSheetWithOptions } = useActionSheet();
- items.forEach((item) => {
- if (!seasons[item.SeasonName!]) {
- seasons[item.SeasonName!] = [];
+ const base64Image = useMemo(() => {
+ return storage.getString(items[0].SeriesId!);
+ }, []);
+
+ const deleteSeries = useCallback(
+ async () => deleteItems(items),
+ [items]
+ );
+
+ const showActionSheet = useCallback(() => {
+ const options = ["Delete", "Cancel"];
+ const destructiveButtonIndex = 0;
+
+ showActionSheetWithOptions({
+ options,
+ destructiveButtonIndex,
+ },
+ (selectedIndex) => {
+ if (selectedIndex == destructiveButtonIndex) {
+ deleteSeries();
+ }
}
-
- seasons[item.SeasonName!].push(item);
- });
-
- return Object.values(seasons).sort(
- (a, b) => a[0].IndexNumber! - b[0].IndexNumber!
);
- }, [items]);
+ }, [showActionSheetWithOptions, deleteSeries]);
return (
-
-
- {items[0].SeriesName}
-
- {items.length}
+ router.push(`/downloads/${items[0].SeriesId}`)}
+ onLongPress={showActionSheet}
+ >
+ {base64Image ? (
+
+
+
+ {items.length}
+
-
+ ) : (
+
+
+
+ )}
- TV-Series
- {groupBySeason.map((seasonItems, seasonIndex) => (
-
-
- {seasonItems[0].SeasonName}
-
- {seasonItems.map((item, index) => (
-
-
-
- ))}
-
- ))}
-
+
+ {items[0].SeriesName}
+ {items[0].ProductionYear}
+
+
+
);
};
diff --git a/components/filters/FilterButton.tsx b/components/filters/FilterButton.tsx
index 00a8296d..6d976b26 100644
--- a/components/filters/FilterButton.tsx
+++ b/components/filters/FilterButton.tsx
@@ -1,9 +1,7 @@
import { Text } from "@/components/common/Text";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
-import { useAtom } from "jotai";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";
@@ -25,7 +23,7 @@ export const FilterButton = ({
queryFn,
queryKey,
set,
- values,
+ values, // selected values
title,
renderItemLabel,
searchFilter,
@@ -36,16 +34,19 @@ export const FilterButton = ({
const [open, setOpen] = useState(false);
const { data: filters } = useQuery({
- queryKey: [queryKey, collectionId],
+ queryKey: ["filters", title, queryKey, collectionId],
queryFn,
staleTime: 0,
+ enabled: !!collectionId && !!queryFn && !!queryKey,
});
- if (filters?.length === 0) return null;
-
return (
<>
- setOpen(true)}>
+ {
+ filters?.length && setOpen(true);
+ }}
+ >
({
? "bg-purple-600 border border-purple-700"
: "bg-neutral-900 border border-neutral-900"
}
+ ${filters?.length === 0 && "opacity-50"}
`}
{...props}
>
0 ? "text-purple-100" : "text-neutral-100"}
+ ${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
text-xs font-semibold`}
>
{title}
diff --git a/components/filters/FilterSheet.tsx b/components/filters/FilterSheet.tsx
index 7f839b99..fe6d9f6a 100644
--- a/components/filters/FilterSheet.tsx
+++ b/components/filters/FilterSheet.tsx
@@ -34,6 +34,34 @@ interface Props extends ViewProps {
const LIMIT = 100;
+/**
+ * FilterSheet Component
+ *
+ * This component creates a bottom sheet modal for filtering and selecting items from a list.
+ *
+ * @template T - The type of items in the list
+ *
+ * @param {Object} props - The component props
+ * @param {boolean} props.open - Whether the bottom sheet is open
+ * @param {function} props.setOpen - Function to set the open state
+ * @param {T[] | null} [props.data] - The full list of items to filter from
+ * @param {T[]} props.values - The currently selected items
+ * @param {function} props.set - Function to update the selected items
+ * @param {string} props.title - The title of the bottom sheet
+ * @param {function} props.searchFilter - Function to filter items based on search query
+ * @param {function} props.renderItemLabel - Function to render the label for each item
+ * @param {boolean} [props.showSearch=true] - Whether to show the search input
+ *
+ * @returns {React.ReactElement} The FilterSheet component
+ *
+ * Features:
+ * - Displays a list of items in a bottom sheet
+ * - Allows searching and filtering of items
+ * - Supports single selection of items
+ * - Loads items in batches for performance optimization
+ * - Customizable item rendering
+ */
+
export const FilterSheet = ({
values,
data: _data,
@@ -65,6 +93,8 @@ export const FilterSheet = ({
return results.slice(0, 100);
}, [search, _data, searchFilter]);
+ // Loads data in batches of LIMIT size, starting from offset,
+ // to implement efficient "load more" functionality
useEffect(() => {
if (!_data || _data.length === 0) return;
const tmp = new Set(data);
@@ -143,23 +173,20 @@ export const FilterSheet = ({
className="mb-4 flex flex-col rounded-xl overflow-hidden"
>
{renderData?.map((item, index) => (
- <>
+
{
- set(
- values.includes(item)
- ? values.filter((i) => i !== item)
- : [item]
- );
- setTimeout(() => {
- setOpen(false);
- }, 250);
+ if (!values.includes(item)) {
+ set([item]);
+ setTimeout(() => {
+ setOpen(false);
+ }, 250);
+ }
}}
- key={index}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
>
{renderItemLabel(item)}
- {values.includes(item) ? (
+ {values.some((i) => i === item) ? (
) : (
@@ -171,7 +198,7 @@ export const FilterSheet = ({
}}
className="h-1 divide-neutral-700 "
>
- >
+
))}
{data.length < (_data?.length || 0) && (
diff --git a/components/filters/ResetFiltersButton.tsx b/components/filters/ResetFiltersButton.tsx
index a99ce724..dfeee025 100644
--- a/components/filters/ResetFiltersButton.tsx
+++ b/components/filters/ResetFiltersButton.tsx
@@ -29,7 +29,7 @@ export const ResetFiltersButton: React.FC = ({ ...props }) => {
setSelectedTags([]);
setSelectedYears([]);
}}
- className="bg-purple-600 rounded-full w-8 h-8 flex items-center justify-center"
+ className="bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1"
{...props}
>
diff --git a/components/filters/_SortButton.tsx b/components/filters/_SortButton.tsx
deleted file mode 100644
index bff476a0..00000000
--- a/components/filters/_SortButton.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import * as DropdownMenu from "zeego/dropdown-menu";
-import { Text } from "@/components/common/Text";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { Ionicons } from "@expo/vector-icons";
-import { useQuery } from "@tanstack/react-query";
-import { useAtom } from "jotai";
-import { TouchableOpacity, View, ViewProps } from "react-native";
-import {
- sortByAtom,
- sortOptions,
- sortOrderAtom,
- sortOrderOptions,
-} from "@/utils/atoms/filters";
-
-interface Props extends ViewProps {
- title: string;
-}
-
-export const SortButton: React.FC = ({ title, ...props }) => {
- const [api] = useAtom(apiAtom);
- const [user] = useAtom(userAtom);
-
- const [sortBy, setSortBy] = useAtom(sortByAtom);
- const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
-
- return (
-
-
-
-
- Sort by
-
-
-
-
-
- {sortOptions?.map((g) => (
- {
- if (next === "on") {
- setSortBy(g);
- } else {
- setSortBy(sortOptions[0]);
- }
- }}
- key={g.key}
- textValue={g.value}
- >
-
-
- ))}
-
-
- {sortOrderOptions.map((g) => (
- {
- if (next === "on") {
- setSortOrder(g);
- } else {
- setSortOrder(sortOrderOptions[0]);
- }
- }}
- key={g.key}
- textValue={g.value}
- >
-
-
- ))}
-
-
-
- );
-};
diff --git a/components/home/LargeMovieCarousel.tsx b/components/home/LargeMovieCarousel.tsx
index 70d64814..11676b88 100644
--- a/components/home/LargeMovieCarousel.tsx
+++ b/components/home/LargeMovieCarousel.tsx
@@ -4,25 +4,29 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
-import { useRouter } from "expo-router";
import { useAtom } from "jotai";
-import React, { useMemo } from "react";
-import { Dimensions, View, ViewProps } from "react-native";
-import { useSharedValue } from "react-native-reanimated";
+import React, { useCallback, useMemo } from "react";
+import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
+import Animated, {
+ runOnJS,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
import Carousel, {
ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
-import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import { useRouter, useSegments } from "expo-router";
+import * as Haptics from "expo-haptics";
interface Props extends ViewProps {}
export const LargeMovieCarousel: React.FC = ({ ...props }) => {
- const router = useRouter();
- const queryClient = useQueryClient();
const [settings] = useSettings();
const ref = React.useRef(null);
@@ -31,6 +35,25 @@ export const LargeMovieCarousel: React.FC = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const { data: sf_carousel, isFetching: l1 } = useQuery({
+ queryKey: ["sf_carousel", user?.Id, settings?.mediaListCollectionIds],
+ queryFn: async () => {
+ if (!api || !user?.Id) return null;
+
+ const response = await getItemsApi(api).getItems({
+ userId: user.Id,
+ tags: ["sf_carousel"],
+ recursive: true,
+ fields: ["Tags"],
+ includeItemTypes: ["BoxSet"],
+ });
+
+ return response.data.Items?.[0].Id || null;
+ },
+ enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
+ staleTime: 60 * 1000,
+ });
+
const onPressPagination = (index: number) => {
ref.current?.scrollTo({
/**
@@ -42,59 +65,33 @@ export const LargeMovieCarousel: React.FC = ({ ...props }) => {
});
};
- const { data: mediaListCollection, isLoading: l1 } = useQuery({
- queryKey: ["mediaListCollection", user?.Id],
- queryFn: async () => {
- if (!api || !user?.Id) return null;
-
- const response = await getItemsApi(api).getItems({
- userId: user.Id,
- tags: ["medialist", "promoted"],
- recursive: true,
- fields: ["Tags"],
- includeItemTypes: ["BoxSet"],
- });
-
- const id = response.data.Items?.find((c) => c.Name === "sf_carousel")?.Id;
- return id || null;
- },
- enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
- staleTime: 0,
- });
-
- const { data: popularItems, isLoading: l2 } = useQuery({
+ const { data: popularItems, isFetching: l2 } = useQuery({
queryKey: ["popular", user?.Id],
queryFn: async () => {
- if (!api || !user?.Id || !mediaListCollection) return [];
+ if (!api || !user?.Id || !sf_carousel) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
- parentId: mediaListCollection,
+ parentId: sf_carousel,
limit: 10,
});
return response.data.Items || [];
},
- enabled: !!api && !!user?.Id && !!mediaListCollection,
- staleTime: 0,
+ enabled: !!api && !!user?.Id && !!sf_carousel,
+ staleTime: 60 * 1000,
});
const width = Dimensions.get("screen").width;
- if (l1 || l2)
- return (
-
-
-
- );
-
+ if (l1 || l2) return null;
if (!popularItems) return null;
return (
= ({ ...props }) => {
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
+ const router = useRouter();
+ const screenWidth = Dimensions.get("screen").width;
const uri = useMemo(() => {
if (!api) return null;
@@ -130,8 +129,8 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return getBackdropUrl({
api,
item,
- quality: 90,
- width: 1000,
+ quality: 70,
+ width: Math.floor(screenWidth * 0.8 * 2),
});
}, [api, item]);
@@ -140,11 +139,41 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return getLogoImageUrlById({ api, item, height: 100 });
}, [item]);
+ const segments = useSegments();
+ const from = segments[2];
+
+ const opacity = useSharedValue(1);
+
+ const handleRoute = useCallback(() => {
+ if (!from) return;
+ const url = itemRouter(item, from);
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ // @ts-ignore
+ if (url) router.push(url);
+ }, [item, from]);
+
+ const tap = Gesture.Tap()
+ .maxDuration(2000)
+ .onBegin(() => {
+ opacity.value = withTiming(0.5, { duration: 100 });
+ })
+ .onEnd(() => {
+ runOnJS(handleRoute)();
+ })
+ .onFinalize(() => {
+ opacity.value = withTiming(1, { duration: 100 });
+ });
+
if (!uri || !logoUri) return null;
return (
-
-
+
+
= ({ item }) => {
/>
-
-
+
+
);
};
diff --git a/components/home/ScrollingCollectionList.tsx b/components/home/ScrollingCollectionList.tsx
index d4164f7d..04dd6004 100644
--- a/components/home/ScrollingCollectionList.tsx
+++ b/components/home/ScrollingCollectionList.tsx
@@ -1,60 +1,119 @@
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { View, ViewProps } from "react-native";
+import {
+ useQuery,
+ type QueryFunction,
+ type QueryKey,
+} from "@tanstack/react-query";
+import { ScrollView, View, ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
-import { HorizontalScroll } from "../common/HorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import SeriesPoster from "../posters/SeriesPoster";
interface Props extends ViewProps {
- title: string;
- loading?: boolean;
+ title?: string | null;
orientation?: "horizontal" | "vertical";
- data?: BaseItemDto[] | null;
- height?: "small" | "large";
disabled?: boolean;
+ queryKey: QueryKey;
+ queryFn: QueryFunction;
}
export const ScrollingCollectionList: React.FC = ({
title,
- data,
orientation = "vertical",
- height = "small",
- loading = false,
disabled = false,
+ queryFn,
+ queryKey,
...props
}) => {
- if (disabled) return null;
+ // console.log(queryKey);
+
+ const { data, isLoading } = useQuery({
+ queryKey: queryKey,
+ queryFn,
+ staleTime: 0,
+ refetchOnMount: true,
+ refetchOnWindowFocus: true,
+ refetchOnReconnect: true,
+ });
+
+ if (disabled || !title) return null;
return (
-
-
+
+
{title}
-
- data={data}
- height={orientation === "vertical" ? 247 : 164}
- loading={loading}
- renderItem={(item, index) => (
-
-
- {orientation === "vertical" ? (
-
- ) : (
-
- )}
-
+ {isLoading === false && data?.length === 0 && (
+
+ No items
+
+ )}
+ {isLoading ? (
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+ Nisi mollit voluptate amet.
+
+
+
+
+ Lorem ipsum
+
+
-
- )}
- />
+ ))}
+
+ ) : (
+
+
+ {data?.map((item, index) => (
+
+ {item.Type === "Episode" && orientation === "horizontal" && (
+
+ )}
+ {item.Type === "Episode" && orientation === "vertical" && (
+
+ )}
+ {item.Type === "Movie" && orientation === "horizontal" && (
+
+ )}
+ {item.Type === "Movie" && orientation === "vertical" && (
+
+ )}
+ {item.Type === "Series" && }
+ {item.Type === "Program" && (
+
+ )}
+
+
+ ))}
+
+
+ )}
);
};
diff --git a/components/icons/JellyseerrIconStatus.tsx b/components/icons/JellyseerrIconStatus.tsx
new file mode 100644
index 00000000..4c1bda37
--- /dev/null
+++ b/components/icons/JellyseerrIconStatus.tsx
@@ -0,0 +1,72 @@
+import {useEffect, useState} from "react";
+import {MediaStatus} from "@/utils/jellyseerr/server/constants/media";
+import {MaterialCommunityIcons} from "@expo/vector-icons";
+import {TouchableOpacity, View, ViewProps} from "react-native";
+import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
+
+interface Props {
+ mediaStatus?: MediaStatus;
+ showRequestIcon: boolean;
+ onPress?: () => void;
+}
+
+const JellyseerrIconStatus: React.FC = ({
+ mediaStatus,
+ showRequestIcon,
+ onPress,
+ ...props
+}) => {
+ const [badgeIcon, setBadgeIcon] = useState();
+ const [badgeStyle, setBadgeStyle] = useState();
+
+ // Match similar to what Jellyseerr is currently using
+ // https://github.com/Fallenbagel/jellyseerr/blob/8a097d5195749c8d1dca9b473b8afa96a50e2fe2/src/components/Common/StatusBadgeMini/index.tsx#L33C1-L62C4
+ useEffect(() => {
+ switch (mediaStatus) {
+ case MediaStatus.PROCESSING:
+ setBadgeStyle('bg-indigo-500 border-indigo-400 ring-indigo-400 text-indigo-100');
+ setBadgeIcon('clock');
+ break;
+ case MediaStatus.AVAILABLE:
+ setBadgeStyle('bg-purple-500 border-green-400 ring-green-400 text-green-100');
+ setBadgeIcon('check')
+ break;
+ case MediaStatus.PENDING:
+ setBadgeStyle('bg-yellow-500 border-yellow-400 ring-yellow-400 text-yellow-100');
+ setBadgeIcon('bell')
+ break;
+ case MediaStatus.BLACKLISTED:
+ setBadgeStyle('bg-red-500 border-white-400 ring-white-400 text-white');
+ setBadgeIcon('eye-off')
+ break;
+ case MediaStatus.PARTIALLY_AVAILABLE:
+ setBadgeStyle('bg-green-500 border-green-400 ring-green-400 text-green-100');
+ setBadgeIcon("minus");
+ break;
+ default:
+ if (showRequestIcon) {
+ setBadgeStyle('bg-green-600');
+ setBadgeIcon("plus")
+ }
+ break;
+ }
+ }, [mediaStatus, showRequestIcon, setBadgeStyle, setBadgeIcon])
+
+ return (
+ badgeIcon &&
+
+
+
+
+
+ )
+}
+
+export default JellyseerrIconStatus;
\ No newline at end of file
diff --git a/components/inputs/Stepper.tsx b/components/inputs/Stepper.tsx
new file mode 100644
index 00000000..eb5032cf
--- /dev/null
+++ b/components/inputs/Stepper.tsx
@@ -0,0 +1,44 @@
+import {TouchableOpacity, View} from "react-native";
+import {Text} from "@/components/common/Text";
+
+interface StepperProps {
+ value: number,
+ step: number,
+ min: number,
+ max: number,
+ onUpdate: (value: number) => void,
+ appendValue?: string,
+}
+
+export const Stepper: React.FC = ({
+ value,
+ step,
+ min,
+ max,
+ onUpdate,
+ appendValue
+}) => {
+ return (
+
+ onUpdate(Math.max(min, value - step))}
+ className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
+ >
+ -
+
+
+ {value}{appendValue}
+
+ onUpdate(Math.min(max, value + step))}
+ >
+ +
+
+
+ )
+}
\ No newline at end of file
diff --git a/components/jellyseerr/DiscoverSlide.tsx b/components/jellyseerr/DiscoverSlide.tsx
new file mode 100644
index 00000000..94a2d4dd
--- /dev/null
+++ b/components/jellyseerr/DiscoverSlide.tsx
@@ -0,0 +1,75 @@
+import React, {useMemo} from "react";
+import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
+import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
+import {DiscoverEndpoint, Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
+import {useInfiniteQuery} from "@tanstack/react-query";
+import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
+import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
+import {Text} from "@/components/common/Text";
+import {FlashList} from "@shopify/flash-list";
+
+interface Props {
+ slide: DiscoverSlider
+}
+const DiscoverSlide: React.FC = ({slide}) => {
+ const {jellyseerrApi} = useJellyseerr();
+
+ const {data, isFetching, fetchNextPage, hasNextPage} = useInfiniteQuery({
+ queryKey: ["jellyseerr", "discover", slide.id],
+ queryFn: async ({ pageParam }) => {
+ let endpoint: DiscoverEndpoint | undefined = undefined;
+ let params: any = {
+ page: Number(pageParam)
+ }
+
+ switch (slide.type) {
+ case DiscoverSliderType.TRENDING:
+ endpoint = Endpoints.DISCOVER_TRENDING;
+ break;
+ case DiscoverSliderType.POPULAR_MOVIES:
+ case DiscoverSliderType.UPCOMING_MOVIES:
+ endpoint = Endpoints.DISCOVER_MOVIES
+ if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
+ params = { ...params, primaryReleaseDateGte: new Date().toISOString().split('T')[0]}
+ break;
+ case DiscoverSliderType.POPULAR_TV:
+ case DiscoverSliderType.UPCOMING_TV:
+ endpoint = Endpoints.DISCOVER_TV
+ if (slide.type === DiscoverSliderType.UPCOMING_TV)
+ params = {...params, firstAirDateGte: new Date().toISOString().split('T')[0]}
+ break;
+ }
+
+ return endpoint ? jellyseerrApi?.discover(endpoint, params) : null;
+ },
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, pages) => ((lastPage?.page || pages?.findLast(p => p?.results.length)?.page) || 1) + 1,
+ enabled: !!jellyseerrApi,
+ staleTime: 0
+ });
+
+ const flatData = useMemo(() => data?.pages?.filter(p => p?.results.length).flatMap(p => p?.results), [data])
+
+ return (
+ (flatData && flatData?.length > 0) && <>
+ {DiscoverSliderType[slide.type].toString().toTitle()}
+ item!!.id.toString()}
+ estimatedItemSize={250}
+ data={flatData}
+ onEndReachedThreshold={1}
+ onEndReached={() => {
+ if (hasNextPage)
+ fetchNextPage()
+ }}
+ renderItem={({item}) =>
+ (item ? : <>>)
+ }
+ />
+ >
+ )
+}
+
+export default DiscoverSlide;
\ No newline at end of file
diff --git a/components/library/LibraryItemCard.tsx b/components/library/LibraryItemCard.tsx
new file mode 100644
index 00000000..0a19d1a9
--- /dev/null
+++ b/components/library/LibraryItemCard.tsx
@@ -0,0 +1,161 @@
+import { Text } from "@/components/common/Text";
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { useSettings } from "@/utils/atoms/settings";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+import { Ionicons } from "@expo/vector-icons";
+import {
+ BaseItemDto,
+ CollectionType,
+} 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 { useAtom } from "jotai";
+import { useMemo } from "react";
+import { TouchableOpacityProps, View } from "react-native";
+import { TouchableItemRouter } from "../common/TouchableItemRouter";
+
+interface Props extends TouchableOpacityProps {
+ library: BaseItemDto;
+}
+
+type IconName = React.ComponentProps["name"];
+
+const icons: Record = {
+ movies: "film",
+ tvshows: "tv",
+ music: "musical-notes",
+ books: "book",
+ homevideos: "videocam",
+ boxsets: "albums",
+ playlists: "list",
+ folders: "folder",
+ livetv: "tv",
+ musicvideos: "musical-notes",
+ photos: "images",
+ trailers: "videocam",
+ unknown: "help-circle",
+} as const;
+export const LibraryItemCard: React.FC = ({ library, ...props }) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+ const [settings] = useSettings();
+
+ const url = useMemo(
+ () =>
+ getPrimaryImageUrl({
+ api,
+ item: library,
+ }),
+ [library]
+ );
+
+ const { data: itemsCount } = useQuery({
+ queryKey: ["library-count", library.Id],
+ queryFn: async () => {
+ if (!api) return null;
+ const response = await getItemsApi(api).getItems({
+ userId: user?.Id,
+ parentId: library.Id,
+ limit: 0,
+ });
+ return response.data.TotalRecordCount;
+ },
+ staleTime: 1000 * 60 * 60,
+ });
+
+ if (!url) return null;
+
+ if (settings?.libraryOptions?.display === "row") {
+ return (
+
+
+
+
+ {library.Name}
+
+ {settings?.libraryOptions?.showStats && (
+
+ {itemsCount} items
+
+ )}
+
+
+ );
+ }
+
+ if (settings?.libraryOptions?.imageStyle === "cover") {
+ return (
+
+
+
+
+
+
+ {settings?.libraryOptions?.showTitles && (
+
+ {library.Name}
+
+ )}
+ {settings?.libraryOptions?.showStats && (
+
+ {itemsCount} items
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {library.Name}
+
+ {settings?.libraryOptions?.showStats && (
+
+ {itemsCount} items
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/components/livetv/HourHeader.tsx b/components/livetv/HourHeader.tsx
new file mode 100644
index 00000000..99344e43
--- /dev/null
+++ b/components/livetv/HourHeader.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+import { View } from "react-native";
+import { Text } from "../common/Text";
+
+export const HourHeader = ({ height }: { height: number }) => {
+ const now = new Date();
+ const currentHour = now.getHours();
+ const hoursRemaining = 24 - currentHour;
+ const hours = generateHours(currentHour, hoursRemaining);
+
+ return (
+
+ {hours.map((hour, index) => (
+
+ ))}
+
+ );
+};
+
+const HourCell = ({ hour }: { hour: Date }) => (
+
+
+ {hour.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ })}
+
+
+);
+
+const generateHours = (startHour: number, count: number): Date[] => {
+ const now = new Date();
+ return Array.from({ length: count }, (_, i) => {
+ const hour = new Date(now);
+ hour.setHours(startHour + i, 0, 0, 0);
+ return hour;
+ });
+};
diff --git a/components/livetv/LiveTVGuideRow.tsx b/components/livetv/LiveTVGuideRow.tsx
new file mode 100644
index 00000000..cbb70d19
--- /dev/null
+++ b/components/livetv/LiveTVGuideRow.tsx
@@ -0,0 +1,96 @@
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { useMemo, useRef } from "react";
+import { Dimensions, View } from "react-native";
+import { Text } from "../common/Text";
+import { TouchableItemRouter } from "../common/TouchableItemRouter";
+
+export const LiveTVGuideRow = ({
+ channel,
+ programs,
+ scrollX = 0,
+ isVisible = true,
+}: {
+ channel: BaseItemDto;
+ programs?: BaseItemDto[] | null;
+ scrollX?: number;
+ isVisible?: boolean;
+}) => {
+ const positionRefs = useRef<{ [key: string]: number }>({});
+ const screenWidth = Dimensions.get("window").width;
+
+ const calculateWidth = (s?: string | null, e?: string | null) => {
+ if (!s || !e) return 0;
+ const start = new Date(s);
+ const end = new Date(e);
+ const duration = end.getTime() - start.getTime();
+ const minutes = duration / 60000;
+ const width = (minutes / 60) * 200;
+ return width;
+ };
+
+ const programsWithPositions = useMemo(() => {
+ let cumulativeWidth = 0;
+ return programs
+ ?.filter((p) => p.ChannelId === channel.Id)
+ .map((p) => {
+ const width = calculateWidth(p.StartDate, p.EndDate);
+ const position = cumulativeWidth;
+ cumulativeWidth += width;
+ return { ...p, width, position };
+ });
+ }, [programs, channel.Id]);
+
+ const isCurrentlyLive = (program: BaseItemDto) => {
+ if (!program.StartDate || !program.EndDate) return false;
+ const now = new Date();
+ const start = new Date(program.StartDate);
+ const end = new Date(program.EndDate);
+ return now >= start && now <= end;
+ };
+
+ if (!isVisible) {
+ return ;
+ }
+
+ return (
+
+ {programsWithPositions?.map((p) => (
+
+
+ {(() => {
+ return (
+ screenWidth && scrollX > p.position
+ ? scrollX - p.position
+ : 0,
+ }}
+ className="px-4 self-start"
+ >
+
+ {p.Name}
+
+
+ );
+ })()}
+
+
+ ))}
+
+ );
+};
diff --git a/components/medialists/MediaListSection.tsx b/components/medialists/MediaListSection.tsx
index 99732831..8474b866 100644
--- a/components/medialists/MediaListSection.tsx
+++ b/components/medialists/MediaListSection.tsx
@@ -4,42 +4,57 @@ import {
BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
-import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
-import { View, ViewProps } from "react-native";
-import { ScrollingCollectionList } from "../home/ScrollingCollectionList";
-import { Text } from "../common/Text";
-import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
-import { TouchableItemRouter } from "../common/TouchableItemRouter";
-import MoviePoster from "../posters/MoviePoster";
import { useCallback } from "react";
+import { View, ViewProps } from "react-native";
+import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
+import { Text } from "../common/Text";
+import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import { ItemCardText } from "../ItemCardText";
+import MoviePoster from "../posters/MoviePoster";
+import {
+ type QueryKey,
+ type QueryFunction,
+ useQuery,
+} from "@tanstack/react-query";
interface Props extends ViewProps {
- collection: BaseItemDto;
+ queryKey: QueryKey;
+ queryFn: QueryFunction;
}
-export const MediaListSection: React.FC = ({ collection, ...props }) => {
+export const MediaListSection: React.FC = ({
+ queryFn,
+ queryKey,
+ ...props
+}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
+ const { data: collection } = useQuery({
+ queryKey,
+ queryFn,
+ staleTime: 0,
+ });
+
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise => {
- if (!api || !user?.Id) return null;
+ if (!api || !user?.Id || !collection) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: collection.Id,
startIndex: pageParam,
- limit: 10,
+ limit: 8,
});
return response.data;
},
- [api, user?.Id, collection.Id]
+ [api, user?.Id, collection?.Id]
);
if (!collection) return null;
@@ -56,11 +71,12 @@ export const MediaListSection: React.FC = ({ collection, ...props }) => {
key={index}
item={item}
className={`flex flex-col
- ${"w-32"}
+ ${"w-28"}
`}
>
+
)}
diff --git a/components/movies/MoviesTitleHeader.tsx b/components/movies/MoviesTitleHeader.tsx
index 71a3f4af..e3dd9fcf 100644
--- a/components/movies/MoviesTitleHeader.tsx
+++ b/components/movies/MoviesTitleHeader.tsx
@@ -1,21 +1,18 @@
-import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
-import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { View, ViewProps } from "react-native";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const MoviesTitleHeader: React.FC = ({ item, ...props }) => {
- const router = useRouter();
return (
- <>
-
-
- {item?.Name}
-
-
- >
+
+
+ {item?.Name}
+
+ {item?.ProductionYear}
+
);
};
diff --git a/components/music/SongsList.tsx b/components/music/SongsList.tsx
index 8b5d2ca7..4d576f3c 100644
--- a/components/music/SongsList.tsx
+++ b/components/music/SongsList.tsx
@@ -1,9 +1,6 @@
-import { TouchableOpacity, View, ViewProps } from "react-native";
-import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import ArtistPoster from "../ArtistPoster";
-import { runtimeTicksToMinutes, runtimeTicksToSeconds } from "@/utils/time";
import { useRouter } from "expo-router";
+import { View, ViewProps } from "react-native";
import { SongsListItem } from "./SongsListItem";
interface Props extends ViewProps {
diff --git a/components/music/SongsListItem.tsx b/components/music/SongsListItem.tsx
index 53abcba8..552baa69 100644
--- a/components/music/SongsListItem.tsx
+++ b/components/music/SongsListItem.tsx
@@ -1,27 +1,18 @@
-import {
- TouchableOpacity,
- TouchableOpacityProps,
- View,
- ViewProps,
-} from "react-native";
import { Text } from "@/components/common/Text";
-import index from "@/app/(auth)/(tabs)/home";
-import { runtimeTicksToSeconds } from "@/utils/time";
-import { router } from "expo-router";
-import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
-import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { chromecastProfile } from "@/utils/profiles/chromecast";
-import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
+import { usePlaySettings } from "@/providers/PlaySettingsProvider";
+import { runtimeTicksToSeconds } from "@/utils/time";
+import { useActionSheet } from "@expo/react-native-action-sheet";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { useRouter } from "expo-router";
+import { useAtom } from "jotai";
+import { useCallback } from "react";
+import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
-import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
-import { useActionSheet } from "@expo/react-native-action-sheet";
-import ios from "@/utils/profiles/ios";
interface Props extends TouchableOpacityProps {
collectionId: string;
@@ -42,12 +33,12 @@ export const SongsListItem: React.FC = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
- const [, setCp] = useAtom(currentlyPlayingItemAtom);
- const [, setPlaying] = useAtom(playingAtom);
-
+ const router = useRouter();
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
+ const { setPlaySettings } = usePlaySettings();
+
const openSelect = () => {
if (!castDevice?.deviceId) {
play("device");
@@ -73,30 +64,23 @@ export const SongsListItem: React.FC = ({
case cancelButtonIndex:
break;
}
- },
+ }
);
};
- const play = async (type: "device" | "cast") => {
- if (!user?.Id || !api || !item.Id) return;
+ const play = useCallback(async (type: "device" | "cast") => {
+ if (!user?.Id || !api || !item.Id) {
+ console.warn("No user, api or item", user, api, item.Id);
+ return;
+ }
- const response = await getMediaInfoApi(api!).getPlaybackInfo({
- itemId: item?.Id,
- userId: user?.Id,
- });
-
- const sessionData = response.data;
-
- const url = await getStreamUrl({
- api,
- userId: user.Id,
+ const data = await setPlaySettings({
item,
- startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
- sessionData,
- deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
});
- if (!url || !item) return;
+ if (!data?.url) {
+ throw new Error("play-music ~ No stream url");
+ }
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
@@ -105,7 +89,7 @@ export const SongsListItem: React.FC = ({
else {
client.loadMedia({
mediaInfo: {
- contentUrl: url,
+ contentUrl: data.url!,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
@@ -118,13 +102,10 @@ export const SongsListItem: React.FC = ({
}
});
} else {
- setCp({
- item,
- playbackUrl: url,
- });
- setPlaying(true);
+ console.log("Playing on device", data.url, item.Id);
+ router.push("/music-player");
}
- };
+ }, []);
return (
= ({ item, id }) => {
if (!item && id)
return (
-
+
= ({
if (!url)
return (
= ({
+ item,
+ showProgress = false,
+}) => {
+ const [api] = useAtom(apiAtom);
+
+ const url = useMemo(() => {
+ if (item.Type === "Episode") {
+ return `${api?.basePath}/Items/${item.ParentBackdropItemId}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ParentThumbImageTag}`;
+ }
+ }, [item]);
+
+ const [progress, setProgress] = useState(
+ item.UserData?.PlayedPercentage || 0
+ );
+
+ const blurhash = useMemo(() => {
+ const key = item.ImageTags?.["Primary"] as string;
+ return item.ImageBlurHashes?.["Primary"]?.[key];
+ }, [item]);
+
+ return (
+
+
+
+ {showProgress && progress > 0 && (
+
+ )}
+
+ );
+};
diff --git a/components/posters/ItemPoster.tsx b/components/posters/ItemPoster.tsx
new file mode 100644
index 00000000..86575ab9
--- /dev/null
+++ b/components/posters/ItemPoster.tsx
@@ -0,0 +1,53 @@
+import { View, ViewProps } from "react-native";
+import { Text } from "@/components/common/Text";
+import {
+ BaseItemDto,
+ BaseItemKind,
+} from "@jellyfin/sdk/lib/generated-client/models";
+import { ItemImage } from "../common/ItemImage";
+import { WatchedIndicator } from "../WatchedIndicator";
+import { useState } from "react";
+
+interface Props extends ViewProps {
+ item: BaseItemDto;
+ showProgress?: boolean;
+}
+
+export const ItemPoster: React.FC = ({
+ item,
+ showProgress,
+ ...props
+}) => {
+ const [progress, setProgress] = useState(
+ item.UserData?.PlayedPercentage || 0
+ );
+
+ if (item.Type === "Movie" || item.Type === "Series" || item.Type === "BoxSet")
+ return (
+
+
+
+ {showProgress && progress > 0 && (
+
+ )}
+
+ );
+
+ return (
+
+
+
+ );
+};
diff --git a/components/posters/JellyseerrPoster.tsx b/components/posters/JellyseerrPoster.tsx
new file mode 100644
index 00000000..5a9647ae
--- /dev/null
+++ b/components/posters/JellyseerrPoster.tsx
@@ -0,0 +1,92 @@
+import {View, ViewProps} from "react-native";
+import {Image} from "expo-image";
+import {MaterialCommunityIcons} from "@expo/vector-icons";
+import {Text} from "@/components/common/Text";
+import {useEffect, useMemo, useState} from "react";
+import {MovieResult, Results, TvResult} from "@/utils/jellyseerr/server/models/Search";
+import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
+import {useJellyseerr} from "@/hooks/useJellyseerr";
+import {hasPermission, Permission} from "@/utils/jellyseerr/server/lib/permissions";
+import {TouchableJellyseerrRouter} from "@/components/common/JellyseerrItemRouter";
+import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
+interface Props extends ViewProps {
+ item: MovieResult | TvResult;
+}
+
+const JellyseerrPoster: React.FC = ({
+ item,
+ ...props
+}) => {
+ const {jellyseerrUser, jellyseerrApi} = useJellyseerr();
+ // const imageSource =
+
+ const imageSrc = useMemo(() =>
+ item.posterPath ?
+ `https://image.tmdb.org/t/p/w300_and_h450_face${item.posterPath}`
+ : jellyseerrApi?.axios?.defaults.baseURL + `/images/overseerr_poster_not_found_logo_top.png`,
+ [item, jellyseerrApi]
+ )
+ const title = useMemo(() => item.mediaType === MediaType.MOVIE ? item.title : item.name, [item])
+ const releaseYear = useMemo(() =>
+ new Date(item.mediaType === MediaType.MOVIE ? item.releaseDate : item.firstAirDate).getFullYear(),
+ [item]
+ )
+
+ const showRequestButton = useMemo(() =>
+ jellyseerrUser && hasPermission(
+ [
+ Permission.REQUEST,
+ item.mediaType === 'movie'
+ ? Permission.REQUEST_MOVIE
+ : Permission.REQUEST_TV,
+ ],
+ jellyseerrUser.permissions,
+ {type: 'or'}
+ ),
+ [item, jellyseerrUser]
+ )
+
+ const canRequest = useMemo(() => {
+ const status = item?.mediaInfo?.status
+ return showRequestButton && !status || status === MediaStatus.UNKNOWN
+ }, [item])
+
+ return (
+
+
+
+
+
+
+
+
+ {title}
+ {releaseYear}
+
+
+
+ )
+}
+
+
+export default JellyseerrPoster;
\ No newline at end of file
diff --git a/components/posters/MoviePoster.tsx b/components/posters/MoviePoster.tsx
index c8e39b88..46776fb7 100644
--- a/components/posters/MoviePoster.tsx
+++ b/components/posters/MoviePoster.tsx
@@ -1,3 +1,4 @@
+import { WatchedIndicator } from "@/components/WatchedIndicator";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
@@ -5,7 +6,6 @@ import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
-import { WatchedIndicator } from "@/components/WatchedIndicator";
type MoviePosterProps = {
item: BaseItemDto;
@@ -18,14 +18,13 @@ const MoviePoster: React.FC = ({
}) => {
const [api] = useAtom(apiAtom);
- const url = useMemo(
- () =>
- getPrimaryImageUrl({
- api,
- item,
- }),
- [item]
- );
+ const url = useMemo(() => {
+ return getPrimaryImageUrl({
+ api,
+ item,
+ width: 300,
+ });
+ }, [item]);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
@@ -37,7 +36,7 @@ const MoviePoster: React.FC = ({
}, [item]);
return (
-
+
= ({ id }) => {
);
return (
-
+
= ({ item, url, blurhash }) => {
);
return (
-
+
= ({ item }) => {
const [api] = useAtom(apiAtom);
- const url = useMemo(
- () =>
- getPrimaryImageUrl({
- api,
- item,
- }),
- [item]
- );
+ const url = useMemo(() => {
+ if (item.Type === "Episode") {
+ return `${api?.basePath}/Items/${item.SeriesId}/Images/Primary?fillHeight=389&quality=80&tag=${item.SeriesPrimaryImageTag}`;
+ }
+ return getPrimaryImageUrl({
+ api,
+ item,
+ width: 300,
+ });
+ }, [item]);
const blurhash = useMemo(() => {
const key = item.ImageTags?.["Primary"] as string;
@@ -30,7 +32,7 @@ const SeriesPoster: React.FC = ({ item }) => {
}, [item]);
return (
-
+
= ({ item }) => {
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
- aspectRatio: "10/15",
+ height: "100%",
width: "100%",
}}
/>
diff --git a/components/series/CastAndCrew.tsx b/components/series/CastAndCrew.tsx
index 1d33e0fb..41dfdbb1 100644
--- a/components/series/CastAndCrew.tsx
+++ b/components/series/CastAndCrew.tsx
@@ -1,36 +1,56 @@
+import { apiAtom } from "@/providers/JellyfinProvider";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import {
BaseItemDto,
BaseItemPerson,
} from "@jellyfin/sdk/lib/generated-client/models";
-import React from "react";
-import { Linking, TouchableOpacity, View } from "react-native";
+import { router } from "expo-router";
+import { useAtom } from "jotai";
+import React, { useMemo } from "react";
+import { TouchableOpacity, View, ViewProps } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";
-import { useAtom } from "jotai";
-import { apiAtom } from "@/providers/JellyfinProvider";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
-import { router } from "expo-router";
-export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
+interface Props extends ViewProps {
+ item?: BaseItemDto | null;
+ loading?: boolean;
+}
+
+export const CastAndCrew: React.FC = ({ item, loading, ...props }) => {
const [api] = useAtom(apiAtom);
+ const destinctPeople = useMemo(() => {
+ const people: BaseItemPerson[] = [];
+ item?.People?.forEach((person) => {
+ const existingPerson = people.find((p) => p.Id === person.Id);
+ if (existingPerson) {
+ existingPerson.Role = `${existingPerson.Role}, ${person.Role}`;
+ } else {
+ people.push(person);
+ }
+ });
+ return people;
+ }, [item?.People]);
+
return (
-
+
Cast & Crew
- >
- data={item.People}
- renderItem={(item, index) => (
+ i.Id.toString()}
+ height={247}
+ data={destinctPeople}
+ renderItem={(i) => (
{
- // TODO: Navigate to person
+ router.push(`/actors/${i.Id}`);
}}
- key={item.Id}
- className="flex flex-col w-32"
+ className="flex flex-col w-28"
>
-
- {item.Name}
- {item.Role}
+
+ {i.Name}
+ {i.Role}
)}
/>
diff --git a/components/series/CurrentSeries.tsx b/components/series/CurrentSeries.tsx
index a71dea8d..e573929a 100644
--- a/components/series/CurrentSeries.tsx
+++ b/components/series/CurrentSeries.tsx
@@ -3,25 +3,30 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
-import { TouchableOpacity, View } from "react-native";
+import { TouchableOpacity, View, ViewProps } from "react-native";
import Poster from "../posters/Poster";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
-export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
+interface Props extends ViewProps {
+ item?: BaseItemDto | null;
+}
+
+export const CurrentSeries: React.FC = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
return (
-
+
Series
-
+ (
router.push(`/series/${item.SeriesId}`)}
- className="flex flex-col space-y-2 w-32"
+ className="flex flex-col space-y-2 w-28"
>
= ({ item, ...props }) => {
+ const router = useRouter();
+
+ return (
+
+
+ {item?.Name}
+
+
+ {
+ router.push(
+ // @ts-ignore
+ `/(auth)/series/${item.SeriesId}?seasonIndex=${item?.ParentIndexNumber}`
+ );
+ }}
+ >
+ {item?.SeasonName}
+
+
+ {"—"}
+ {`Episode ${item.IndexNumber}`}
+
+ {item?.ProductionYear}
+
+ );
+};
diff --git a/components/series/JellyseerrSeasons.tsx b/components/series/JellyseerrSeasons.tsx
new file mode 100644
index 00000000..a62689b9
--- /dev/null
+++ b/components/series/JellyseerrSeasons.tsx
@@ -0,0 +1,215 @@
+import {Text} from "@/components/common/Text";
+import React, {useCallback, useMemo, useState} from "react";
+import {Alert, TouchableOpacity, View} from "react-native";
+import {TvDetails} from "@/utils/jellyseerr/server/models/Tv";
+import {FlashList} from "@shopify/flash-list";
+import {orderBy} from "lodash";
+import {Tags} from "@/components/GenreTags";
+import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
+import Season from "@/utils/jellyseerr/server/entity/Season";
+import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media";
+import {Ionicons} from "@expo/vector-icons";
+import {RoundButton} from "@/components/RoundButton";
+import {useJellyseerr} from "@/hooks/useJellyseerr";
+import {TvResult} from "@/utils/jellyseerr/server/models/Search";
+import {useQuery} from "@tanstack/react-query";
+import {HorizontalScroll} from "@/components/common/HorrizontalScroll";
+import {Image} from "expo-image";
+import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
+
+const JellyseerrSeasonEpisodes: React.FC<{details: TvDetails, seasonNumber: number}> = ({
+ details,
+ seasonNumber
+}) => {
+ const {jellyseerrApi} = useJellyseerr();
+
+ const {data: seasonWithEpisodes, isLoading} = useQuery({
+ queryKey: ["jellyseerr", details.id, "season", seasonNumber],
+ queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
+ enabled: details.seasons.filter(s => s.seasonNumber !== 0).length > 0
+ })
+
+ return (
+ item.id}
+ ItemSeparatorComponent={() => }
+ renderItem={(item, index) => (
+
+ {item.stillPath && (
+
+
+
+ )}
+
+
+ {item?.name}
+
+
+ {`S${item?.seasonNumber}:E${item?.episodeNumber}`}
+
+
+
+
+ {item?.overview}
+
+
+ )}
+ />
+ )
+}
+
+const JellyseerrSeasons: React.FC<{
+ isLoading: boolean,
+ result?: TvResult,
+ details?: TvDetails
+}> = ({
+ isLoading,
+ result,
+ details,
+}) => {
+ if (!details)
+ return null;
+
+ const {jellyseerrApi, requestMedia} = useJellyseerr();
+ const [seasonStates, setSeasonStates] = useState<{[key: number]: boolean}>();
+ const seasons = useMemo(() => {
+ const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter((s: Season) => s.seasonNumber !== 0)
+ const requestedSeasons = details?.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons)
+ return details.seasons?.map((season) => {
+ return {
+ ...season,
+ status:
+ // What our library status is
+ mediaInfoSeasons
+ ?.find((mediaSeason: Season) => mediaSeason.seasonNumber === season.seasonNumber)
+ ?.status
+ ??
+ // What our request status is
+ requestedSeasons
+ ?.find((s: Season) => s.seasonNumber === season.seasonNumber)
+ ?.status
+ ??
+ // Otherwise set it as unknown
+ MediaStatus.UNKNOWN
+ }
+ })
+ },
+ [details]
+ );
+
+ const allSeasonsAvailable = useMemo(() =>
+ seasons?.every(season => season.status === MediaStatus.AVAILABLE),
+ [seasons]
+ )
+
+ const requestAll = useCallback(() => {
+ if (details && jellyseerrApi) {
+ requestMedia(result?.name!!, {
+ mediaId: details.id,
+ mediaType: MediaType.TV,
+ tvdbId: details.externalIds?.tvdbId,
+ seasons: seasons
+ .filter(s => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0)
+ .map(s => s.seasonNumber)
+ })
+ }
+ }, [jellyseerrApi, seasons, details])
+
+ const promptRequestAll = useCallback(() => (
+ Alert.alert('Request all?', 'Are you sure you want to request all seasons?', [
+ {
+ text: 'Cancel',
+ style: 'cancel',
+ },
+ {
+ text: 'YES',
+ onPress: requestAll
+ },
+ ])), [requestAll]);
+
+ return (
+ s.seasonNumber !== 0), 'seasonNumber', 'desc')}
+ ListHeaderComponent={() => (
+
+ Seasons
+ {!allSeasonsAvailable && (
+
+
+
+ )}
+
+ )}
+ ItemSeparatorComponent={() => }
+ estimatedItemSize={250}
+ renderItem={({item: season}) => (
+ <>
+ setSeasonStates((prevState) => (
+ {...prevState, [season.seasonNumber]: !prevState?.[season.seasonNumber]}
+ ))}
+ >
+
+
+ {[0].map(() => {
+ const canRequest = seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status === MediaStatus.UNKNOWN
+ return
+ requestMedia(
+ `${result?.name!!}, Season ${season.seasonNumber}`,
+ {
+ mediaId: details.id,
+ mediaType: MediaType.TV,
+ tvdbId: details.externalIds?.tvdbId,
+ seasons: [season.seasonNumber]
+ }
+ ) : undefined
+ }
+ className={canRequest ? 'bg-gray-700/40' : undefined}
+ mediaStatus={seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status}
+ showRequestIcon={canRequest}
+ />
+ })}
+
+
+ {seasonStates?.[season.seasonNumber] && (
+
+ )}
+ >
+ )
+ }
+ />
+ )
+}
+
+export default JellyseerrSeasons;
\ No newline at end of file
diff --git a/components/series/NextEpisodeButton.tsx b/components/series/NextItemButton.tsx
similarity index 56%
rename from components/series/NextEpisodeButton.tsx
rename to components/series/NextItemButton.tsx
index 835d8334..02520c9a 100644
--- a/components/series/NextEpisodeButton.tsx
+++ b/components/series/NextItemButton.tsx
@@ -13,7 +13,7 @@ interface Props extends React.ComponentProps {
type?: "next" | "previous";
}
-export const NextEpisodeButton: React.FC = ({
+export const NextItemButton: React.FC = ({
item,
type = "next",
...props
@@ -23,42 +23,8 @@ export const NextEpisodeButton: React.FC = ({
const [user] = useAtom(userAtom);
const [api] = useAtom(apiAtom);
- // const { data: seasons } = useQuery({
- // queryKey: ["seasons", item.SeriesId],
- // queryFn: async () => {
- // if (
- // !api ||
- // !user?.Id ||
- // !item?.Id ||
- // !item?.SeriesId ||
- // !item?.IndexNumber
- // )
- // return [];
-
- // const response = await getItemsApi(api).getItems({
- // parentId: item?.SeriesId,
- // });
-
- // console.log("seasons ~", type, response.data);
-
- // return (response.data.Items as BaseItemDto[]) ?? [];
- // },
- // enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
- // });
-
- // const nextSeason = useMemo(() => {
- // if (!seasons) return null;
- // const currentSeasonIndex = seasons.findIndex(
- // (season) => season.Id === item.SeasonId,
- // );
-
- // if (currentSeasonIndex === seasons.length - 1) return null;
-
- // return seasons[currentSeasonIndex + 1];
- // }, [seasons]);
-
- const { data: nextEpisode } = useQuery({
- queryKey: ["nextEpisode", item.Id, item.ParentId, type],
+ const { data: nextItem } = useQuery({
+ queryKey: ["nextItem", item.Id, item.ParentId, type],
queryFn: async () => {
if (
!api ||
@@ -81,16 +47,16 @@ export const NextEpisodeButton: React.FC = ({
});
const disabled = useMemo(() => {
- if (!nextEpisode) return true;
- if (nextEpisode.Id === item.Id) return true;
+ if (!nextItem) return true;
+ if (nextItem.Id === item.Id) return true;
return false;
- }, [nextEpisode, type]);
+ }, [nextItem, type]);
if (item.Type !== "Episode") return null;
return (
router.replace(`/items/${nextEpisode?.Id}`)}
+ onPress={() => router.setParams({ id: nextItem?.Id })}
className={`h-12 aspect-square`}
disabled={disabled}
{...props}
diff --git a/components/series/NextUp.tsx b/components/series/NextUp.tsx
index 346a9227..ce87bb19 100644
--- a/components/series/NextUp.tsx
+++ b/components/series/NextUp.tsx
@@ -1,17 +1,16 @@
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
+import { useQuery } from "@tanstack/react-query";
+import { router } from "expo-router";
+import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
-import Poster from "../posters/Poster";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
-import { router } from "expo-router";
-import { useQuery } from "@tanstack/react-query";
-import { useAtom } from "jotai";
-import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
-import { nextUp } from "@/utils/jellyfin/tvshows/nextUp";
-import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
+import { TouchableItemRouter } from "../common/TouchableItemRouter";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
@@ -26,6 +25,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
userId: user?.Id,
seriesId,
fields: ["MediaSourceCount"],
+ limit: 10,
})
).data.Items;
},
@@ -35,7 +35,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
if (!items?.length)
return (
-
+
Next up
No items to display
@@ -44,19 +44,17 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
return (
Next up
-
+ (
- {
- router.push(`/(auth)/items/${item.Id}`);
- }}
- key={item.Id}
- className="flex flex-col w-32"
+
-
+
-
+
)}
/>
diff --git a/components/series/SeasonDropdown.tsx b/components/series/SeasonDropdown.tsx
new file mode 100644
index 00000000..574048d4
--- /dev/null
+++ b/components/series/SeasonDropdown.tsx
@@ -0,0 +1,121 @@
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { useEffect, useMemo } from "react";
+import { TouchableOpacity, View } from "react-native";
+import * as DropdownMenu from "zeego/dropdown-menu";
+import { Text } from "../common/Text";
+
+type Props = {
+ item: BaseItemDto;
+ seasons: BaseItemDto[];
+ initialSeasonIndex?: number;
+ state: SeasonIndexState;
+ onSelect: (season: BaseItemDto) => void;
+};
+
+type SeasonKeys = {
+ id: keyof BaseItemDto;
+ title: keyof BaseItemDto;
+ index: keyof BaseItemDto;
+};
+
+export type SeasonIndexState = {
+ [seriesId: string]: number | null | undefined;
+};
+
+export const SeasonDropdown: React.FC = ({
+ item,
+ seasons,
+ initialSeasonIndex,
+ state,
+ onSelect,
+}) => {
+ const keys = useMemo(
+ () =>
+ item.Type === "Episode"
+ ? {
+ id: "ParentId",
+ title: "SeasonName",
+ index: "ParentIndexNumber",
+ }
+ : {
+ id: "Id",
+ title: "Name",
+ index: "IndexNumber",
+ },
+ [item]
+ );
+
+ const seasonIndex = useMemo(
+ () => state[(item[keys.id] as string) ?? ""],
+ [state]
+ );
+
+ useEffect(() => {
+ if (seasons && seasons.length > 0 && seasonIndex === undefined) {
+ let initialIndex: number | undefined;
+
+ if (initialSeasonIndex !== undefined) {
+ // Use the provided initialSeasonIndex if it exists in the seasons
+ const seasonExists = seasons.some(
+ (season: any) => season[keys.index] === initialSeasonIndex
+ );
+ if (seasonExists) {
+ initialIndex = initialSeasonIndex;
+ }
+ }
+
+ if (initialIndex === undefined) {
+ // Fall back to the previous logic if initialIndex is not set
+ const season1 = seasons.find((season: any) => season[keys.index] === 1);
+ const season0 = seasons.find((season: any) => season[keys.index] === 0);
+ const firstSeason = season1 || season0 || seasons[0];
+ onSelect(firstSeason);
+ }
+
+ if (initialIndex !== undefined) {
+ const initialSeason = seasons.find(
+ (season: any) => season[keys.index] === initialIndex
+ );
+
+ if (initialSeason) onSelect(initialSeason!);
+ else throw Error("Initial index could not be found!");
+ }
+ }
+ }, [seasons, seasonIndex, item[keys.id], initialSeasonIndex]);
+
+ const sortByIndex = (a: BaseItemDto, b: BaseItemDto) =>
+ Number(a[keys.index]) - Number(b[keys.index]);
+
+ return (
+
+
+
+
+ Season {seasonIndex}
+
+
+
+
+ Seasons
+ {seasons?.sort(sortByIndex).map((season: any) => (
+ onSelect(season)}
+ >
+
+ {season[keys.title]}
+
+
+ ))}
+
+
+ );
+};
diff --git a/components/series/SeasonEpisodesCarousel.tsx b/components/series/SeasonEpisodesCarousel.tsx
new file mode 100644
index 00000000..e03d590d
--- /dev/null
+++ b/components/series/SeasonEpisodesCarousel.tsx
@@ -0,0 +1,143 @@
+import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { router } from "expo-router";
+import { useAtom } from "jotai";
+import { useEffect, useMemo, useRef } from "react";
+import { TouchableOpacity, View, ViewProps } from "react-native";
+import {
+ HorizontalScroll,
+ HorizontalScrollRef,
+} from "../common/HorrizontalScroll";
+import ContinueWatchingPoster from "../ContinueWatchingPoster";
+import { ItemCardText } from "../ItemCardText";
+
+interface Props extends ViewProps {
+ item?: BaseItemDto | null;
+ loading?: boolean;
+}
+
+export const SeasonEpisodesCarousel: React.FC = ({
+ item,
+ loading,
+ ...props
+}) => {
+ const [api] = useAtom(apiAtom);
+ const [user] = useAtom(userAtom);
+
+ const scrollRef = useRef(null);
+
+ const scrollToIndex = (index: number) => {
+ scrollRef.current?.scrollToIndex(index, 16);
+ };
+
+ const seasonId = useMemo(() => {
+ return item?.SeasonId;
+ }, [item]);
+
+ const {
+ data: episodes,
+ isLoading,
+ isFetching,
+ } = useQuery({
+ queryKey: ["episodes", seasonId],
+ queryFn: async () => {
+ if (!api || !user?.Id) return [];
+ const response = await api.axiosInstance.get(
+ `${api.basePath}/Shows/${item?.Id}/Episodes`,
+ {
+ params: {
+ userId: user?.Id,
+ seasonId,
+ Fields:
+ "ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
+ },
+ headers: {
+ Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
+ },
+ }
+ );
+
+ return response.data.Items as BaseItemDto[];
+ },
+ enabled: !!api && !!user?.Id && !!seasonId,
+ });
+
+ /**
+ * Prefetch previous and next episode
+ */
+ const queryClient = useQueryClient();
+ useEffect(() => {
+ if (!item?.Id || !item.IndexNumber || !episodes || episodes.length === 0) {
+ return;
+ }
+
+ const previousId = episodes?.find(
+ (ep) => ep.IndexNumber === item.IndexNumber! - 1
+ )?.Id;
+ if (previousId) {
+ queryClient.prefetchQuery({
+ queryKey: ["item", previousId],
+ queryFn: async () =>
+ await getUserItemData({
+ api,
+ userId: user?.Id,
+ itemId: previousId,
+ }),
+ staleTime: 60 * 1000 * 5,
+ });
+ }
+
+ const nextId = episodes?.find(
+ (ep) => ep.IndexNumber === item.IndexNumber! + 1
+ )?.Id;
+ if (nextId) {
+ queryClient.prefetchQuery({
+ queryKey: ["item", nextId],
+ queryFn: async () =>
+ await getUserItemData({
+ api,
+ userId: user?.Id,
+ itemId: nextId,
+ }),
+ staleTime: 60 * 1000 * 5,
+ });
+ }
+ }, [episodes, api, user?.Id, item]);
+
+ useEffect(() => {
+ if (item?.Type === "Episode" && item.Id) {
+ const index = episodes?.findIndex((ep) => ep.Id === item.Id);
+ if (index !== undefined && index !== -1) {
+ setTimeout(() => {
+ scrollToIndex(index);
+ }, 400);
+ }
+ }
+ }, [episodes, item]);
+
+ return (
+ (
+ {
+ router.setParams({ id: _item.Id });
+ }}
+ className={`flex flex-col w-44
+ ${item?.Id === _item.Id ? "" : "opacity-50"}
+ `}
+ >
+
+
+
+ )}
+ {...props}
+ />
+ );
+};
diff --git a/components/series/SeasonPicker.tsx b/components/series/SeasonPicker.tsx
index 5e117437..00093cd4 100644
--- a/components/series/SeasonPicker.tsx
+++ b/components/series/SeasonPicker.tsx
@@ -1,28 +1,36 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
+import { runtimeTicksToSeconds } from "@/utils/time";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
-import { useQuery } from "@tanstack/react-query";
-import { useRouter } from "expo-router";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { atom, useAtom } from "jotai";
-import { useMemo } from "react";
-import { TouchableOpacity, View } from "react-native";
-import * as DropdownMenu from "zeego/dropdown-menu";
+import { useEffect, useMemo, useState } from "react";
+import { View } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
-import { ItemCardText } from "../ItemCardText";
-import { HorizontalScroll } from "../common/HorrizontalScroll";
+import { DownloadItems, DownloadSingleItem } from "../DownloadItem";
+import { Loader } from "../Loader";
import { Text } from "../common/Text";
+import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
+import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
+import { TouchableItemRouter } from "../common/TouchableItemRouter";
+import {
+ SeasonDropdown,
+ SeasonIndexState,
+} from "@/components/series/SeasonDropdown";
+import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
type Props = {
item: BaseItemDto;
+ initialSeasonIndex?: number;
};
-export const seasonIndexAtom = atom(1);
+export const seasonIndexAtom = atom({});
-export const SeasonPicker: React.FC = ({ item }) => {
+export const SeasonPicker: React.FC = ({ item, initialSeasonIndex }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
- const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
+ const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
- const router = useRouter();
+ const seasonIndex = seasonIndexState[item.Id ?? ""];
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
@@ -40,7 +48,7 @@ export const SeasonPicker: React.FC = ({ item }) => {
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
- },
+ }
);
return response.data.Items;
@@ -51,84 +59,134 @@ export const SeasonPicker: React.FC = ({ item }) => {
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
- [seasons, seasonIndex],
+ [seasons, seasonIndex]
);
- const { data: episodes } = useQuery({
+ const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
- if (!api || !user?.Id || !item.Id) return [];
- const response = await api.axiosInstance.get(
- `${api.basePath}/Shows/${item.Id}/Episodes`,
- {
- params: {
- userId: user?.Id,
- seasonId: selectedSeasonId,
- Fields:
- "ItemCounts,PrimaryImageAspectRatio,CanDelete,MediaSourceCount,Overview",
- },
- headers: {
- Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
- },
- },
- );
+ if (!api || !user?.Id || !item.Id || !selectedSeasonId) return [];
+ const res = await getTvShowsApi(api).getEpisodes({
+ seriesId: item.Id,
+ userId: user.Id,
+ seasonId: selectedSeasonId,
+ enableUserData: true,
+ fields: ["MediaSources", "MediaStreams", "Overview"],
+ });
- return response.data.Items as BaseItemDto[];
+ return res.data.Items;
},
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
+ const queryClient = useQueryClient();
+ useEffect(() => {
+ for (let e of episodes || []) {
+ queryClient.prefetchQuery({
+ queryKey: ["item", e.Id],
+ queryFn: async () => {
+ if (!e.Id) return;
+ const res = await getUserItemData({
+ api,
+ userId: user?.Id,
+ itemId: e.Id,
+ });
+ return res;
+ },
+ staleTime: 60 * 5 * 1000,
+ });
+ }
+ }, [episodes]);
+
+ // Used for height calculation
+ const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
+ useEffect(() => {
+ if (episodes && episodes.length > 0) {
+ setNrOfEpisodes(episodes.length);
+ }
+ }, [episodes]);
+
return (
-
-
-