Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
86dfcc6b7f chore 2024-08-11 09:59:55 +02:00
121 changed files with 2302 additions and 8733 deletions

View File

@@ -1,26 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone15Pro]
- OS: [e.g. iOS18]
- Version [e.g. 0.3.1]

View File

@@ -1,14 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

4
.gitignore vendored
View File

@@ -26,7 +26,3 @@ Streamyfin.app
/android
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.ipa
.continuerc.json

View File

@@ -12,63 +12,18 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
## 🌟 Features
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 📺 **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.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
- 📱 Native video player: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 📥 Download media (Experimental): Save your media locally and watch it offline.
- 📡 Chromecast media (Experimental): Cast your media to any Chromecast-enabled device.
## 🧪 Experimental Features
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
### Downloading
## 🛠️ TestFlight (pending review)
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.
### 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 build 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 an collection to provide this functionality.
Avaiable tags:
- sf_promoted: Wil make the collection an row on home
- sf_carousel: Wil make the collection an carousel on home.
A plugin exists to create collections based on external sources like mdblist. This makes managing collections like trending, most watched etc an automatic process.
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
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
## Get it now
<div style="display: flex; gap: 5px;">
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
</div>
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.
Soon iOS users can test Streamyfin in beta via TestFlight. To join the beta program, click the link below.
<a href="https://testflight.apple.com/join/CWBaAAK2">
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
@@ -87,12 +42,6 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
### Development info
1. Use node `20`
2. Install deps `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:
```
@@ -134,17 +83,11 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
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
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
## 📝 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.

View File

@@ -2,43 +2,35 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.8.2",
"orientation": "default",
"version": "0.0.6",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#29164B"
"backgroundColor": "#ffffff"
},
"jsEngine": "hermes",
"assetBundlePatterns": ["**/*"],
"ios": {
"requireFullScreen": true,
"userInterfaceStyle": "dark",
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": ["audio"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
}
"NSMicrophoneUsageDescription": "The app needs access to your microphone."
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin"
},
"android": {
"jsEngine": "hermes",
"versionCode": 23,
"jsEngine": "jsc",
"userInterfaceStyle": "light",
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
"foregroundImage": "./assets/images/icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.fredrikburmester.streamyfin",
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
]
"package": "com.fredrikburmester.streamyfin"
},
"web": {
"bundler": "metro",
@@ -48,35 +40,37 @@
"plugins": [
"expo-router",
"expo-font",
"react-native-compressor",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
[
"react-native-video",
{
"enableNotificationControls": true,
"enableBackgroundAudio": true,
"androidExtensions": {
"useExoplayerRtsp": false,
"useExoplayerSmoothStreaming": false,
"useExoplayerHls": true,
"useExoplayerHls": false,
"useExoplayerDash": false
}
}
],
[
"react-native-vlc-media-player",
{
"ios": {
"includeVLCKit": false
},
"android": {
"legacyJetifier": false
}
}
],
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "14.0"
},
"ios": { "deploymentTarget": "14.0" },
"android": {
"minSdkVersion": 24,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
"useLegacyPackaging": true
@@ -84,18 +78,6 @@
}
}
}
],
[
"expo-screen-orientation",
{
"initialOrientation": "DEFAULT"
}
],
[
"expo-sensors",
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
]
],
"experiments": {
@@ -109,12 +91,6 @@
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
},
"owner": "fredrikburmester",
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
"owner": "fredrikburmester"
}
}

View File

@@ -1,58 +1,25 @@
import { router, Tabs } from "expo-router";
import React, { useEffect } from "react";
import * as NavigationBar from "expo-navigation-bar";
import React from "react";
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
import { Platform, TouchableOpacity, View } from "react-native";
import { TouchableOpacity } from "react-native";
import { Feather } from "@expo/vector-icons";
import { Chromecast } from "@/components/Chromecast";
import { BlurView } from "expo-blur";
import { StyleSheet } from "react-native";
export default function TabLayout() {
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setBackgroundColorAsync("#121212");
NavigationBar.setBorderColorAsync("#121212");
}
}, []);
return (
<Tabs
initialRouteName="home"
screenOptions={{
tabBarActiveTintColor: Colors.tabIconSelected,
headerShown: false,
tabBarStyle: {
position: "absolute",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
borderTopWidth: 0,
paddingTop: 8,
paddingBottom: Platform.OS === "android" ? 8 : 26,
height: Platform.OS === "android" ? 58 : 74,
},
tabBarBackground: () =>
Platform.OS === "ios" ? (
<BlurView
experimentalBlurMethod="dimezisBlurView"
intensity={95}
style={{
...StyleSheet.absoluteFillObject,
overflow: "hidden",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
backgroundColor: "black",
}}
/>
) : undefined,
}}
>
<Tabs.Screen redirect name="index" />
<Tabs.Screen
name="home"
name="index"
options={{
headerShown: false,
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
title: "Home",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
@@ -60,31 +27,40 @@ export default function TabLayout() {
color={color}
/>
),
headerLeft: () => (
<TouchableOpacity
style={{ marginHorizontal: 17 }}
onPress={() => {
router.push("/(auth)/downloads");
}}
>
<Feather name="download" color={"white"} size={24} />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
style={{ marginHorizontal: 17 }}
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={24} />
</TouchableOpacity>
),
}}
/>
<Tabs.Screen
name="search"
options={{
headerShown: false,
headerStyle: { backgroundColor: "black" },
headerShown: true,
headerShadowVisible: false,
title: "Search",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon name={focused ? "search" : "search"} color={color} />
),
}}
/>
<Tabs.Screen
name="libraries"
options={{
headerShown: false,
title: "Library",
tabBarIcon: ({ color, focused }) => (
<TabBarIcon
name={focused ? "apps" : "apps-outline"}
color={color}
/>
),
}}
/>
</Tabs>
);
}

View File

@@ -1,50 +0,0 @@
import { Chromecast } from "@/components/Chromecast";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, View } from "react-native";
import { TouchableOpacity } from "react-native";
export default function IndexLayout() {
const router = useRouter();
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Home",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
style={{
marginRight: Platform.OS === "android" ? 17 : 0,
}}
onPress={() => {
router.push("/(auth)/downloads");
}}
>
<Feather name="download" color={"white"} size={22} />
</TouchableOpacity>
),
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
<Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
>
<View className="h-10 aspect-square flex items-center justify-center rounded">
<Feather name="settings" color={"white"} size={22} />
</View>
</TouchableOpacity>
</View>
),
}}
/>
</Stack>
);
}

View File

@@ -1,299 +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 { RefreshControl, ScrollView, View } from "react-native";
export default function index() {
const router = useRouter();
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(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<BaseItemDto[]>({
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"],
limit: 20,
})
).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<BaseItemDto[]>({
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<BaseItemDto[]>({
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: ["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: 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: ["sf_promoted"],
});
await queryClient.refetchQueries({
queryKey: ["sf_carousel"],
});
setLoading(false);
}, [queryClient, user?.Id]);
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
</Button>
</View>
</View>
);
}
if (isError)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View>
);
if (isLoading)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior="automatic"
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
>
<View className="flex flex-col pt-4 pb-24 gap-y-4">
<LargeMovieCarousel />
<ScrollingCollectionList
title="Continue Watching"
data={data}
loading={isLoading}
orientation="horizontal"
/>
<ScrollingCollectionList
title="Next Up"
data={nextUpData}
loading={isLoadingNextUp}
orientation="horizontal"
/>
{mediaListCollections?.map((ml) => (
<MediaListSection key={ml.Id} collection={ml} />
))}
<ScrollingCollectionList
title="Recently Added in Movies"
data={recentlyAddedInMovies}
loading={isLoadingRecentlyAddedMovies}
/>
<ScrollingCollectionList
title="Recently Added in TV-Shows"
data={recentlyAddedInTVShows}
loading={isLoadingRecentlyAddedTVShows}
/>
<ScrollingCollectionList
title="Suggestions"
data={suggestions}
loading={isLoadingSuggestions}
orientation="horizontal"
/>
</View>
</ScrollView>
);
}

View File

@@ -1,5 +1,365 @@
const Index = () => {
return null;
};
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
BaseItemDto,
ItemFields,
ItemFilter,
} from "@jellyfin/sdk/lib/generated-client/models";
import {
getChannelsApi,
getItemsApi,
getSuggestionsApi,
getTvShowsApi,
getUserApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
import { Button } from "@/components/Button";
import { Ionicons } from "@expo/vector-icons";
import MoviePoster from "@/components/MoviePoster";
export default Index;
export default function index() {
const router = useRouter();
const queryClient = useQueryClient();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [loading, setLoading] = useState(false);
const { data, isLoading, isError } = useQuery<BaseItemDto[]>({
queryKey: ["resumeItems", user?.Id],
queryFn: async () =>
(api &&
(
await getItemsApi(api).getResumeItems({
userId: user?.Id,
})
).data.Items) ||
[],
enabled: !!api && !!user?.Id,
staleTime: 60,
});
const { data: _nextUpData } = 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: ["collections", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) {
return [];
}
const data = (
await getItemsApi(api).getItems({
userId: user.Id,
})
).data;
const order = ["boxsets", "tvshows", "movies"];
const cs = data.Items?.sort((a, b) => {
if (
order.indexOf(a.CollectionType!) < order.indexOf(b.CollectionType!)
) {
return 1;
}
return -1;
});
return data.Items || [];
},
enabled: !!api && !!user?.Id,
staleTime: 0,
});
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 } = useQuery<BaseItemDto[]>({
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,
});
const { data: recentlyAddedInTVShows } = useQuery<BaseItemDto[]>({
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,
});
const { data: suggestions } = 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,
});
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.refetchQueries({ queryKey: ["resumeItems", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["items", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["suggestions", user?.Id] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
setLoading(false);
}, [queryClient, user?.Id]);
const [isConnected, setIsConnected] = useState<boolean | null>(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();
};
}, []);
if (isConnected === false) {
return (
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
<Text className="text-3xl font-bold mb-2">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
Go to downloads
</Button>
</View>
</View>
);
}
if (isError)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View>
);
if (isLoading)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
</View>
);
return (
<ScrollView
nestedScrollEnabled
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
>
<View className="flex flex-col py-4 gap-y-4">
<View>
<Text className="px-4 text-2xl font-bold mb-2">
Continue Watching
</Text>
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<View>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">Next Up</Text>
<HorizontalScroll<BaseItemDto>
data={nextUpData}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<View>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">
Recently Added in Movies
</Text>
<HorizontalScroll<BaseItemDto>
data={recentlyAddedInMovies}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-32"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">
Recently Added in TV-Shows
</Text>
<HorizontalScroll<BaseItemDto>
data={recentlyAddedInTVShows}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/series/${item.Id}/page`)}
className="flex flex-col w-32"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">Collections</Text>
<HorizontalScroll<BaseItemDto>
data={collections}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/collections/${item.Id}/page`)}
className="flex flex-col w-48"
>
<View>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableOpacity>
)}
/>
</View>
<View>
<Text className="px-4 text-2xl font-bold mb-2">Suggestions</Text>
<HorizontalScroll<BaseItemDto>
data={suggestions}
renderItem={(item, index) => (
<TouchableOpacity
key={index}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
</View>
</View>
</ScrollView>
);
}

View File

@@ -1,434 +0,0 @@
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,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { FlatList, 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 MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
genreFilterAtom,
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
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";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
const Page = () => {
const searchParams = useLocalSearchParams();
const { libraryId } = searchParams as { libraryId: 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 [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
useLayoutEffect(() => {
setSortBy([
{
key: "SortName",
value: "Name",
},
]);
setSortOrder([
{
key: "Ascending",
value: "Ascending",
},
]);
}, []);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const { data: library } = 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: 0,
});
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null;
let includeItemTypes: BaseItemKind[] | undefined = [];
switch (library?.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:
includeItemTypes = undefined;
break;
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: libraryId,
limit: 20,
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,
libraryId,
library,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]
);
const { data, isFetching, fetchNextPage, hasNextPage } = 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 }) => (
<MemoizedTouchableItemRouter
key={item.Id}
style={{
width: "100%",
marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
}}
item={item}
>
<View
style={{
alignSelf:
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
),
[orientation]
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className="">
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="genreFilter"
queryFn={async () => {
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: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="yearFilter"
queryFn={async () => {
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: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="tagsFilter"
queryFn={async () => {
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: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="sortBy"
queryFn={async () => sortOptions}
set={setSortBy}
values={sortBy}
title="Sort By"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className="mr-1"
collectionId={libraryId}
queryKey="sortOrder"
queryFn={async () => sortOrderOptions}
set={setSortOrder}
values={sortOrder}
title="Sort Order"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
</View>
),
[
libraryId,
api,
user?.Id,
selectedGenres,
setSelectedGenres,
selectedYears,
setSelectedYears,
selectedTags,
setSelectedTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
]
);
if (!library) return null;
return (
<FlashList
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
</View>
}
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={() => (
<View
style={{
width: 10,
height: 10,
}}
></View>
)}
/>
);
};
export default React.memo(Page);

View File

@@ -1,30 +0,0 @@
import { Stack, useRouter } from "expo-router";
import { Platform } from "react-native";
export default function IndexLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Library",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="[libraryId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -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 (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
return (
<FlashList
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{
paddingTop: 17,
paddingHorizontal: 17,
paddingBottom: 150,
}}
data={data}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() => <View className="h-4" />}
estimatedItemSize={200}
/>
);
}
interface Props {
library: BaseItemDto;
}
const LibraryItemCard: React.FC<Props> = ({ library }) => {
const router = useRouter();
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item: library,
}),
[library]
);
if (!url) return null;
return (
<TouchableOpacity
onPress={() => {
router.push(`/libraries/${library.Id}`);
}}
>
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
<Image
source={{ uri: url }}
style={{
width: "100%",
height: "100%",
borderRadius: 8,
position: "absolute",
top: 0,
left: 0,
}}
/>
<Text className="font-bold text-xl text-start px-4">
{library.Name}
</Text>
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,193 @@
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { ItemCardText } from "@/components/ItemCardText";
import MoviePoster from "@/components/MoviePoster";
import Poster from "@/components/Poster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
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 } from "expo-router";
import { useAtom } from "jotai";
import React, { useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
export default function search() {
const [search, setSearch] = useState<string>("");
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: movies } = useQuery({
queryKey: ["search-movies", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Movie"],
});
return searchApi.data.SearchHints;
},
});
const { data: series } = useQuery({
queryKey: ["search-series", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Series"],
});
return searchApi.data.SearchHints;
},
});
const { data: episodes } = useQuery({
queryKey: ["search-episodes", search],
queryFn: async () => {
if (!api || !user || search.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: search,
limit: 10,
includeItemTypes: ["Episode"],
});
return searchApi.data.SearchHints;
},
});
return (
<ScrollView keyboardDismissMode="on-drag">
<View className="flex flex-col py-2">
<View className="mb-4 px-4">
<Input
autoCorrect={false}
returnKeyType="done"
keyboardType="web-search"
placeholder="Search here..."
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
<Text className="font-bold text-2xl px-4 mb-2">Movies</Text>
<SearchItemWrapper
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-32"
onPress={() => router.push(`/items/${item.Id}/page`)}
>
<MoviePoster item={item} key={item.Id} />
<Text className="mt-2">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<Text className="font-bold text-2xl px-4 my-2">Series</Text>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}/page`)}
className="flex flex-col w-32"
>
<Poster
item={item}
key={item.Id}
url={getPrimaryImageUrl({ api, item })}
/>
<Text className="mt-2">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<Text className="font-bold text-2xl px-4 my-2">Episodes</Text>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-48"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
)}
/>
</View>
</ScrollView>
);
}
type Props = {
ids?: string[] | null;
renderItem: (data: BaseItemDto[]) => React.ReactNode;
};
const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem }) => {
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 <Text className="opacity-50 text-xs px-4">No results</Text>;
return renderItem(data);
};

View File

@@ -1,20 +0,0 @@
import { Stack } from "expo-router";
import { Platform } from "react-native";
export default function SearchLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{
headerShown: true,
headerLargeTitle: true,
headerTitle: "Search",
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -1,493 +0,0 @@
import { Button } from "@/components/Button";
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 { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Ionicons } from "@expo/vector-icons";
import { Api } from "@jellyfin/sdk";
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,
usePathname,
} from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
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 params = useLocalSearchParams();
const { q, prev } = params as { q: string; prev: Href<string> };
const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [settings] = useSettings();
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<BaseItemDto[]> => {
if (!api) return [];
if (searchEngine === "Jellyfin") {
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: query,
limit: 10,
includeItemTypes: types,
});
return searchApi.data.SearchHints as BaseItemDto[];
} else {
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[];
}
},
[api, 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: debouncedSearch.length > 0,
});
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Series"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: episodes, isFetching: l3 } = useQuery({
queryKey: ["search", "episodes", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Episode"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: collections, isFetching: l7 } = useQuery({
queryKey: ["search", "collections", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["BoxSet"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: actors, isFetching: l8 } = useQuery({
queryKey: ["search", "actors", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Person"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: artists, isFetching: l4 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicArtist"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: albums, isFetching: l5 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: songs, isFetching: l6 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Audio"],
}),
enabled: debouncedSearch.length > 0,
});
const noResults = useMemo(() => {
return !(
artists?.length ||
albums?.length ||
songs?.length ||
movies?.length ||
episodes?.length ||
series?.length ||
collections?.length ||
actors?.length
);
}, [artists, episodes, albums, songs, movies, series, collections, actors]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
return (
<>
<ScrollView
keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior="automatic"
>
<View className="flex flex-col pt-4 pb-32">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
autoCorrect={false}
returnKeyType="done"
keyboardType="web-search"
placeholder="Search here..."
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
)}
{!!q && (
<View className="px-4 flex flex-col space-y-2">
<Text className="text-neutral-500 ">
Results for <Text className="text-purple-600">{q}</Text>
</Text>
</View>
)}
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-28"
onPress={() => router.push(`/items/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}`)}
className="flex flex-col w-28"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}`)}
className="flex flex-col w-44"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-28"
onPress={() => router.push(`/collections/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableOpacity>
)}
/>
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(data) => (
<HorizontalScroll<BaseItemDto>
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
)}
/>
{loading ? (
<View className="mt-4 flex justify-center items-center">
<Loader />
</View>
) : noResults && debouncedSearch.length > 0 ? (
<View>
<Text className="text-center text-lg font-bold mt-4">
No results found for
</Text>
<Text className="text-xs text-purple-600 text-center">
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => setSearch(e)}
key={e}
className="mb-2"
>
<Text className="text-purple-600">{e}</Text>
</TouchableOpacity>
))}
</View>
) : null}
</View>
</ScrollView>
</>
);
}
type Props = {
ids?: string[] | null;
renderItem: (data: BaseItemDto[]) => React.ReactNode;
header?: string;
};
const SearchItemWrapper: React.FC<Props> = ({ 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 (
<>
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
{renderItem(data)}
</>
);
};

View File

@@ -1,151 +0,0 @@
import { Bitrate } from "@/components/BitrateSelector";
import { Loader } from "@/components/Loader";
import { OverviewText } from "@/components/OverviewText";
import { Ratings } from "@/components/Ratings";
import { Text } from "@/components/common/Text";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
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 { getItemsApi, 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 { useCastDevice } from "react-native-google-cast";
import { ParallaxScrollView } from "../../../components/ParallaxPage";
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { ItemCardText } from "@/components/ItemCardText";
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
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<BaseItemDtoQueryResult | null> => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [actorId],
startIndex: pageParam,
limit: 8,
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 (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="flex flex-col space-y-4 my-4">
<View className="px-4 mb-4">
<MoviesTitleHeader item={item} className="mb-4" />
<OverviewText text={item.Overview} />
</View>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
Appeared In
</Text>
<InfiniteHorizontalScroll
height={247}
renderItem={(i, idx) => (
<TouchableItemRouter
key={idx}
item={i}
className={`flex flex-col
${"w-28"}
`}
>
<View>
<MoviePoster item={i} />
<ItemCardText item={i} />
</View>
</TouchableItemRouter>
)}
queryFn={fetchItems}
queryKey={["actor", "movies", actorId]}
/>
<View className="h-12"></View>
</View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -1,128 +0,0 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { SongsList } from "@/components/music/SongsList";
import ArtistPoster from "@/components/posters/ArtistPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId, artistId, albumId } = searchParams as {
collectionId: string;
artistId: string;
albumId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const { data: album } = useQuery({
queryKey: ["album", albumId, artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [albumId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!albumId,
staleTime: 0,
});
const {
data: songs,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["songs", artistId, albumId],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: albumId,
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
if (!album) return null;
return (
<ScrollView>
<View className="px-4 pb-24">
<View className="flex flex-row space-x-4 items-start mb-4">
<View className="w-24">
<ArtistPoster item={album} />
</View>
<View className="flex flex-col shrink">
<Text className="font-bold text-3xl">{album?.Name}</Text>
<Text className="">{album?.ProductionYear}</Text>
<View className="flex flex-row space-x-2 mt-1">
{album.AlbumArtists?.map((a) => (
<TouchableOpacity
key={a.Id}
onPress={() => {
router.push(`/artists/${a.Id}/page`);
}}
>
<Text className="font-bold text-purple-600">
{album?.AlbumArtist}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
<SongsList
albumId={albumId}
songs={songs?.Items}
collectionId={collectionId}
artistId={artistId}
/>
</View>
</ScrollView>
);
}

View File

@@ -1,131 +0,0 @@
import ArtistPoster from "@/components/posters/ArtistPoster";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { artistId } = searchParams as {
artistId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [startIndex, setStartIndex] = useState<number>(0);
const { data: artist } = useQuery({
queryKey: ["album", artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [artistId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!artistId,
staleTime: 0,
});
const {
data: albums,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["albums", artistId, startIndex],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: artistId,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["MusicAlbum"],
recursive: true,
fields: [
"ParentId",
"PrimaryImageAspectRatio",
"ParentId",
"PrimaryImageAspectRatio",
],
collapseBoxSetItems: false,
albumArtistIds: [artistId],
startIndex,
limit: 100,
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
useEffect(() => {
navigation.setOptions({
title: albums?.Items?.[0]?.AlbumArtist || "",
});
}, [albums]);
if (!artist || !albums) return null;
return (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-2">
<View className="w-32 mb-4">
<ArtistPoster item={artist} />
</View>
<Text className="font-bold text-2xl mb-4">Albums</Text>
</View>
}
nestedScrollEnabled
data={albums.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableOpacity
style={{ width: "30%" }}
key={index}
onPress={() => {
router.push(`/albums/${item.Id}`);
}}
>
<View className="flex flex-col gap-y-2">
<ArtistPoster item={item} />
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableOpacity>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

View File

@@ -1,118 +0,0 @@
import { Text } from "@/components/common/Text";
import ArtistPoster from "@/components/posters/ArtistPoster";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getArtistsApi, 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 { FlatList, TouchableOpacity, View } from "react-native";
export default function page() {
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<number>(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 response = await getArtistsApi(api).getArtists({
sortBy: ["SortName"],
sortOrder: ["Ascending"],
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
parentId: collectionId,
userId: user?.Id,
});
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]);
if (!data) return null;
return (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-4">
<Text className="font-bold text-3xl mb-2">Artists</Text>
</View>
}
nestedScrollEnabled
data={data.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableOpacity
style={{
maxWidth: "30%",
width: "100%",
}}
key={index}
onPress={() => {
router.push(`/artists/${item.Id}/page`);
}}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && (
<MoviePoster item={item} />
)}
{collection?.CollectionType === "music" && (
<ArtistPoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableOpacity>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

View File

@@ -1,402 +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 {
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 { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { FlatList, NativeScrollEvent, ScrollView, View } from "react-native";
import * as ScreenOrientation from "expo-screen-orientation";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
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);
useLayoutEffect(() => {
setSortBy([
{
key: "PremiereDate",
value: "Premiere Date",
},
]);
setSortOrder([
{
key: "Ascending",
value: "Ascending",
},
]);
}, []);
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 || "" });
}, [navigation, collection]);
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !collection) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 18,
startIndex: pageParam,
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
sortOrder: [sortOrder[0].key],
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
genres: selectedGenres,
tags: selectedTags,
years: selectedYears.map((year) => parseInt(year)),
});
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 }) => (
<MemoizedTouchableItemRouter
key={item.Id}
style={{
width: "100%",
marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
}}
item={item}
>
<View
style={{
alignSelf:
index % 3 === 0
? "flex-end"
: (index + 1) % 3 === 0
? "flex-start"
: "center",
width: "89%",
}}
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
),
[orientation]
);
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
const ListHeaderComponent = useCallback(
() => (
<View className="">
<FlatList
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
display: "flex",
paddingHorizontal: 15,
paddingVertical: 16,
flexDirection: "row",
}}
data={[
{
key: "reset",
component: <ResetFiltersButton />,
},
{
key: "genre",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="genreFilter"
queryFn={async () => {
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: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="yearFilter"
queryFn={async () => {
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: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="tagsFilter"
queryFn={async () => {
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: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="sortBy"
queryFn={async () => sortOptions}
set={setSortBy}
values={sortBy}
title="Sort By"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
{
key: "sortOrder",
component: (
<FilterButton
className="mr-1"
collectionId={collectionId}
queryKey="sortOrder"
queryFn={async () => sortOrderOptions}
set={setSortOrder}
values={sortOrder}
title="Sort Order"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
),
},
]}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
/>
</View>
),
[
collectionId,
api,
user?.Id,
selectedGenres,
setSelectedGenres,
selectedYears,
setSelectedYears,
selectedTags,
setSelectedTags,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
isFetching,
]
);
if (!collection) return null;
return (
<FlashList
ListEmptyComponent={
<View className="flex flex-col items-center justify-center h-full">
<Text className="font-bold text-xl text-neutral-500">No results</Text>
</View>
}
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={() => (
<View
style={{
width: 10,
height: 10,
}}
></View>
)}
/>
);
};
export default page;

View File

@@ -0,0 +1,186 @@
import { Text } from "@/components/common/Text";
import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } 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 {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collection: collectionId } = searchParams as { collection: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () =>
(api &&
(
await getItemsApi(api).getItems({
userId: user?.Id,
})
).data.Items?.find((item) => item.Id == collectionId)) ||
null,
enabled: !!api && !!user?.Id,
staleTime: 0,
});
const [startIndex, setStartIndex] = useState<number>(0);
const { data, isLoading, isError } = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collectionId, startIndex],
queryFn: async () => {
if (!api) return [];
const response = await api.axiosInstance.get(
`${api.basePath}/Users/${user?.Id}/Items`,
{
params: {
SortBy:
collection?.CollectionType === "movies"
? "SortName,ProductionYear"
: "SortName",
SortOrder: "Ascending",
IncludeItemTypes:
collection?.CollectionType === "movies" ? "Movie" : "Series",
Recursive: true,
Fields:
collection?.CollectionType === "movies"
? "PrimaryImageAspectRatio,MediaSourceCount"
: "PrimaryImageAspectRatio",
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
ParentId: collectionId,
Limit: 100,
StartIndex: startIndex,
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
);
return response.data || [];
},
enabled: !!collection && !!api,
});
const totalItems = useMemo(() => {
return data?.TotalRecordCount;
}, [data]);
return (
<ScrollView>
<View>
<View className="px-4 mb-4">
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
<View className="flex flex-row items-center justify-between">
<Text>
{startIndex + 1}-{startIndex + 100} of {totalItems}
</Text>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => Math.max(prev - 100, 0));
}}
>
<Ionicons
name="arrow-back-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => prev + 100);
}}
>
<Ionicons
name="arrow-forward-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
</View>
</View>
</View>
{isLoading ? (
<View className="my-12">
<ActivityIndicator color={"white"} />
</View>
) : (
<View className="flex flex-row flex-wrap">
{data?.Items?.map((item: any, index: number) => (
<TouchableOpacity
style={{
maxWidth: "33%",
width: "100%",
padding: 10,
}}
key={index}
onPress={() => {
if (collection?.CollectionType === "movies") {
router.push(`/items/${item.Id}/page`);
} else if (collection?.CollectionType === "tvshows") {
router.push(`/series/${item.Id}/page`);
}
}}
>
<View className="flex flex-col gap-y-2">
<MoviePoster item={item} />
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</View>
</TouchableOpacity>
))}
</View>
)}
</View>
{!isLoading && (
<View className="flex flex-row items-center space-x-2 justify-center mt-4 mb-12">
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => Math.max(prev - 100, 0));
}}
>
<Ionicons
name="arrow-back-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => prev + 100);
}}
>
<Ionicons
name="arrow-forward-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
};
export default page;

View File

@@ -1,35 +1,34 @@
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 { useEffect, useMemo } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { runningProcesses } from "@/utils/atoms/downloads";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { FFmpegKit } from "ffmpeg-kit-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],
queryKey: ["downloaded_files"],
queryFn: async () =>
JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
(await AsyncStorage.getItem("downloaded_files")) || "[]",
) as BaseItemDto[],
staleTime: 0,
});
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
[downloadedFiles]
[downloadedFiles],
);
const groupedBySeries = useMemo(() => {
@@ -42,6 +41,8 @@ const downloads: React.FC = () => {
return Object.values(series);
}, [downloadedFiles]);
const [process, setProcess] = useAtom(runningProcesses);
const eta = useMemo(() => {
const length = process?.item?.RunTimeTicks || 0;
@@ -56,7 +57,7 @@ const downloads: React.FC = () => {
if (isLoading) {
return (
<View className="h-full flex flex-col items-center justify-center -mt-6">
<Loader />
<ActivityIndicator size="small" color="white" />
</View>
);
}
@@ -64,82 +65,50 @@ const downloads: React.FC = () => {
return (
<ScrollView>
<View className="px-4 py-4">
<View className="mb-4 flex flex-col space-y-4">
<View>
<Text className="text-2xl font-bold mb-2">Queue</Text>
<View className="flex flex-col space-y-2">
{queue.map((q) => (
<TouchableOpacity
onPress={() => 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"
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<TouchableOpacity
onPress={() => {
setQueue((prev) => prev.filter((i) => i.id !== q.id));
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
</TouchableOpacity>
))}
</View>
{queue.length === 0 && (
<Text className="opacity-50">No items in queue</Text>
)}
</View>
<View>
<Text className="text-2xl font-bold mb-2">Active download</Text>
{process?.item ? (
<TouchableOpacity
onPress={() => 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"
>
<View>
<Text className="font-semibold">{process.item.Name}</Text>
<Text className="text-xs opacity-50">
{process.item.Type}
<View className="mb-4">
<Text className="text-2xl font-bold mb-2">Active download</Text>
{process?.item ? (
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/items/${process.item.Id}/page`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{process.item.Name}</Text>
<Text className="text-xs opacity-50">{process.item.Type}</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-red-600">
<Text className="text-xs">
{process.progress.toFixed(0)}%
</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs">
{process.progress.toFixed(0)}%
</Text>
<Text className="text-xs">
{process.speed?.toFixed(2)}x
</Text>
<View>
<Text className="text-xs">ETA {eta}</Text>
</View>
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
<View>
<Text className="text-xs">ETA {eta}</Text>
</View>
</View>
<TouchableOpacity
onPress={() => {
FFmpegKit.cancel();
setProcess(null);
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
<View
className={`
absolute bottom-0 left-0 h-1 bg-purple-600
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
></View>
</View>
<TouchableOpacity
onPress={() => {
FFmpegKit.cancel();
setProcess(null);
}}
>
<Ionicons name="close" size={24} color="red" />
</TouchableOpacity>
) : (
<Text className="opacity-50">No active downloads</Text>
)}
</View>
<View
className={`
absolute bottom-0 left-0 h-1 bg-red-600
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
></View>
</TouchableOpacity>
) : (
<Text className="opacity-50">No active downloads</Text>
)}
</View>
{movies.length > 0 && (
<View className="mb-4">

View File

@@ -1,245 +0,0 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
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 { useMemo, useState } from "react";
import { View } from "react-native";
import { useCastDevice } 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 [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
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 backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item]
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
[item]
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<SeriesTitleHeader item={item} />
) : (
<>
<MoviesTitleHeader item={item} />
</>
)}
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
<Ratings item={item} />
</View>
<View className="flex flex-row justify-between items-center mb-2">
{playbackUrl ? (
<DownloadItem item={item} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
<PlayedStatus item={item} />
</View>
<OverviewText text={item.Overview} />
</View>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<AudioTrackSelector
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton item={item} url={playbackUrl} className="grow" />
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<CastAndCrew item={item} />
{item.Type === "Episode" && (
<View className="mb-4">
<CurrentSeries item={item} />
</View>
)}
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -0,0 +1,208 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem";
import { PlayedStatus } from "@/components/PlayedStatus";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SimilarItems } from "@/components/SimilarItems";
import { VideoPlayer } from "@/components/VideoPlayer";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { ParallaxScrollView } from "../../../../components/ParallaxPage";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
const page: React.FC = () => {
const local = useLocalSearchParams();
const { id } = local as { id: string };
const [playbackURL, setPlaybackURL] = useState<string | null>(null);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
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 backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 90,
width: 1000,
}),
[item],
);
const logoUrl = useMemo(
() => (item?.Type === "Movie" ? getLogoImageUrlById({ api, item }) : null),
[item],
);
if (l1)
return (
<View className="justify-center items-center h-full">
<ActivityIndicator />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 mb-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<>
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/series/${item.SeriesId}/page`)
}
>
<Text className="text-center opacity-50">
{item?.SeriesName}
</Text>
</TouchableOpacity>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
<PlayedStatus item={item} />
</View>
<View>
<View className="flex flex-row items-center self-center">
<TouchableOpacity onPress={() => {}}>
<Text className="text-center opacity-50">
{item?.SeasonName}
</Text>
</TouchableOpacity>
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
<Text className="text-center opacity-50">
{`Episode ${item.IndexNumber}`}
</Text>
</View>
</View>
<Text className="text-center opacity-50">
{item.ProductionYear}
</Text>
</>
) : (
<>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
<PlayedStatus item={item} />
</View>
<Text className="text-center opacity-50">
{item?.ProductionYear}
</Text>
</>
)}
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackURL && (
<DownloadItem item={item} playbackURL={playbackURL} />
)}
// <Chromecast />
</View>
<Text>{item.Overview}</Text>
</View>
<View className="flex flex-col p-4">
<VideoPlayer
itemId={item.Id}
onChangePlaybackURL={(val) => {
setPlaybackURL(val);
}}
/>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Video</Text>
<Text className="text-sm opacity-70">Audio</Text>
<Text className="text-sm opacity-70">Subtitles</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Video")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
<Text className="text-sm opacity-70">
{
item.MediaStreams?.find((i) => i.Type === "Subtitle")
?.DisplayTitle
}
</Text>
</View>
</View>
</ScrollView>
<CastAndCrew item={item} />
{item.Type === "Episode" && (
<View className="mb-4">
<CurrentSeries item={item} />
</View>
)}
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -0,0 +1,23 @@
import { OfflineVideoPlayer } from "@/components/OfflineVideoPlayer";
import * as FileSystem from "expo-file-system";
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { itemId, url } = searchParams as { itemId: string; url: string };
const fileUrl = useMemo(() => {
const u = FileSystem.documentDirectory + url;
return u;
}, [url]);
if (!fileUrl) return null;
return (
<View className="h-screen w-screen items-center justify-center">
{url && <OfflineVideoPlayer url={fileUrl} />}
</View>
);
}

View File

@@ -10,15 +10,12 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { View } from "react-native";
const page: React.FC = () => {
const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as {
id: string;
seasonIndex: string;
};
const { id: seriesId } = params as { id: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -32,7 +29,7 @@ const page: React.FC = () => {
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: 60 * 1000,
staleTime: 0,
});
const backdropUrl = useMemo(
@@ -43,7 +40,7 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item]
[item],
);
const logoUrl = useMemo(
@@ -52,7 +49,7 @@ const page: React.FC = () => {
api,
item,
}),
[item]
[item],
);
if (!item || !backdropUrl) return null;
@@ -87,7 +84,7 @@ const page: React.FC = () => {
</>
}
>
<View className="flex flex-col pt-4">
<View className="flex flex-col pt-4 pb-12">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>

View File

@@ -4,12 +4,11 @@ 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 * as FileSystem from "expo-file-system";
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";
import { WebSocketsTest } from "@/components/settings/WebsocketsText";
export default function settings() {
const { logout } = useJellyfin();
@@ -29,13 +28,11 @@ export default function settings() {
<View className="p-4 flex flex-col gap-y-4 pb-12">
<Text className="font-bold text-2xl">Information</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<View className="rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-neutral-900">
<ListItem title="User" subTitle={user?.Name} />
<ListItem title="Server" subTitle={api?.basePath} />
</View>
<SettingToggles />
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
@@ -45,7 +42,7 @@ export default function settings() {
onPress={async () => {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
Haptics.NotificationFeedbackType.Success,
);
}}
>
@@ -56,7 +53,7 @@ export default function settings() {
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
Haptics.NotificationFeedbackType.Success,
);
}}
>
@@ -67,17 +64,19 @@ export default function settings() {
<Text className="font-bold text-2xl">Logs</Text>
<View className="flex flex-col space-y-2">
{logs?.map((log, index) => (
<View key={index} className="bg-neutral-900 rounded-xl p-3">
<View
key={index}
className="bg-neutral-800 border border-neutral-900 rounded p-2"
>
<Text
className={`
mb-1
${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"}
`}
>
{log.level}
</Text>
<Text className="text-xs">{log.message}</Text>
<Text>{log.message}</Text>
</View>
))}
{logs?.length === 0 && (

View File

@@ -1,271 +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 { 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 { usePlayback } from "@/providers/PlaybackProvider";
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 { setCurrentlyPlayingState } = usePlayback();
const castDevice = useCastDevice();
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
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 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 {
setCurrentlyPlayingState({
item,
url: playbackUrl,
});
}
},
[playbackUrl, item]
);
if (l1)
return (
<View className="justify-center items-center h-full">
<Loader />
</View>
);
if (!item?.Id || !backdropUrl) return null;
return (
<ParallaxScrollView
headerImage={
<Image
source={{
uri: backdropUrl,
}}
style={{
width: "100%",
height: "100%",
}}
/>
}
logo={
<>
{logoUrl ? (
<Image
source={{
uri: logoUrl,
}}
style={{
height: 130,
width: "100%",
resizeMode: "contain",
}}
/>
) : null}
</>
}
>
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
<MoviesTitleHeader item={item} />
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? (
<DownloadItem item={item} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
</View>
</View>
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
selected={maxBitrate}
/>
<AudioTrackSelector
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton item={item} className="grow" />
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">
<View className="flex flex-col">
<Text className="text-sm opacity-70">Audio</Text>
</View>
<View className="flex flex-col">
<Text className="text-sm opacity-70">
{item.MediaStreams?.find((i) => i.Type === "Audio")?.DisplayTitle}
</Text>
</View>
</View>
</ScrollView>
<SimilarItems itemId={item.Id} />
<View className="h-12"></View>
</ParallaxScrollView>
);
};
export default page;

View File

@@ -1,19 +1,16 @@
import { Link, Stack, usePathname } from "expo-router";
import { Link, Stack } from "expo-router";
import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
import { useEffect } from "react";
export default function NotFoundScreen() {
const pathname = usePathname();
return (
<>
<Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<Link href={"/home"} style={styles.link}>
<Link href={"/"} style={styles.link}>
<ThemedText type="link">Go to home screen!</ThemedText>
</Link>
</ThemedView>

View File

@@ -1,28 +1,23 @@
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaybackProvider } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
import { Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { Provider as JotaiProvider } from "jotai";
import { useEffect, useRef } from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider as JotaiProvider } from "jotai";
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { TouchableOpacity } from "react-native";
import Feather from "@expo/vector-icons/Feather";
import { StatusBar } from "expo-status-bar";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export const unstable_settings = {
initialRouteName: "/index",
initialRouteName: "(auth)/(tabs)/",
};
export default function RootLayout() {
@@ -30,6 +25,20 @@ export default function RootLayout() {
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
}),
);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
@@ -41,154 +50,79 @@ export default function RootLayout() {
}
return (
<JotaiProvider>
<Layout />
</JotaiProvider>
);
}
function Layout() {
const [settings, updateSettings] = useSettings();
useKeepAwake();
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retryOnMount: true,
},
},
})
);
useEffect(() => {
if (settings?.autoRotate === true)
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
else
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}, [settings]);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>
<JobQueueProvider>
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/actors/[actorId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/artists/[artistId]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/albums/[albumId]"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/songs/[songId]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</PlaybackProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>
</JobQueueProvider>
</QueryClientProvider>
</GestureHandlerRootView>
<QueryClientProvider client={queryClientRef.current}>
<JotaiProvider>
<JellyfinProvider>
<StatusBar style="auto" />
<ThemeProvider value={DarkTheme}>
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "Home",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
presentation: "modal",
headerLeft: () => (
<TouchableOpacity onPress={() => router.back()}>
<Feather name="x-circle" size={24} color="white" />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
}}
/>
<Stack.Screen
name="(auth)/player/offline/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "transparent" },
}}
/>
<Stack.Screen
name="(auth)/items/[id]/page"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collection]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "transparent" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]/page"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
</ThemeProvider>
</JellyfinProvider>
</JotaiProvider>
</QueryClientProvider>
);
}

View File

@@ -1,189 +0,0 @@
import { Button } from "@/components/Button";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { AxiosError } from "axios";
import { useAtom } from "jotai";
import React, { useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
SafeAreaView,
View,
} from "react-native";
import { z } from "zod";
const CredentialsSchema = z.object({
username: z.string().min(1, "Username is required"),
});
const Login: React.FC = () => {
const { setServer, login, removeServer } = useJellyfin();
const [api] = useAtom(apiAtom);
const [serverURL, setServerURL] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
password: string;
}>({
username: "",
password: "",
});
const [loading, setLoading] = useState<boolean>(false);
const handleLogin = async () => {
setLoading(true);
try {
const result = CredentialsSchema.safeParse(credentials);
if (result.success) {
await login(credentials.username, credentials.password);
}
} catch (error) {
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.");
return;
}
setServer({ address: url.trim() });
};
if (api?.basePath) {
return (
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1, height: "100%" }}
>
<View className="flex flex-col justify-between px-4 h-full gap-y-2">
<View></View>
<View>
<View className="mb-4">
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
<Text className="text-neutral-500 mb-2">
Server: {api.basePath}
</Text>
<Button
color="black"
onPress={() => {
removeServer();
setServerURL("");
}}
justify="between"
iconLeft={
<Ionicons
name="arrow-back-outline"
size={18}
color={"white"}
/>
}
>
Change server
</Button>
</View>
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-neutral-500">
Log in to any user account
</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
setCredentials({ ...credentials, username: text })
}
value={credentials.username}
autoFocus
secureTextEntry={false}
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="username"
clearButtonMode="while-editing"
maxLength={500}
/>
<Input
className="mb-2"
placeholder="Password"
onChangeText={(text) =>
setCredentials({ ...credentials, password: text })
}
value={credentials.password}
secureTextEntry
keyboardType="default"
returnKeyType="done"
autoCapitalize="none"
textContentType="password"
clearButtonMode="while-editing"
maxLength={500}
/>
</View>
<Text className="text-red-600 mb-2">{error}</Text>
</View>
<Button
onPress={handleLogin}
loading={loading}
className="mt-auto mb-2"
>
Log in
</Button>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<View className="flex flex-col px-4 justify-between h-full">
<View></View>
<View className="flex flex-col gap-y-2">
<Text className="text-3xl font-bold">Streamyfin</Text>
<Text className="text-neutral-500">
Connect to your Jellyfin server
</Text>
<Input
placeholder="Server URL"
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Text className="opacity-30">
Server URL requires http or https
</Text>
</View>
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
Connect
</Button>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
};
export default Login;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,40 +0,0 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_DE_RGB_blk_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M39.3926,14.69775H35.67092V8.731h.92676V13.8457H39.3926Z" style="fill: #fff"/>
<path d="M40.32912,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,40.32912,13.42432Zm2.89453-.38477v-.37646L42.124,12.7334c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,43.22365,13.03955Z" style="fill: #fff"/>
<path d="M45.27639,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074H48.6299v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C46,14.772,45.27639,13.87061,45.27639,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C46.64943,10.91846,46.19436,11.49707,46.19436,12.44434Z" style="fill: #fff"/>
<path d="M54.74709,13.48193a1.828,1.828,0,0,1-1.95117,1.30273,2.04531,2.04531,0,0,1-2.08008-2.32422A2.07685,2.07685,0,0,1,52.792,10.10791c1.25293,0,2.00879.856,2.00879,2.27V12.688H51.62111v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,51.62111,12.03076Z" style="fill: #fff"/>
<path d="M55.99416,10.19482h.85547v.71533H56.916a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M63.51955,8.86328a.57572.57572,0,1,1,.5752.5415A.54735.54735,0,0,1,63.51955,8.86328Zm.13281,1.33154h.88477v4.50293h-.88477Z" style="fill: #fff"/>
<path d="M65.97121,10.19482h.85547v.72363h.06641a1.36385,1.36385,0,0,1,2.49316,0h.07031a1.46325,1.46325,0,0,1,1.36914-.81055,1.33821,1.33821,0,0,1,1.43848,1.48828v3.10156h-.88867V11.82813c0-.60791-.29-.90576-.873-.90576a.91167.91167,0,0,0-.9502.94287v2.83252h-.873V11.74121a.78468.78468,0,0,0-.86816-.81885.96854.96854,0,0,0-.95117,1.02148v2.75391h-.88867Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -1,7 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["nativewind/babel", "react-native-reanimated/plugin"],
presets: ['babel-preset-expo'],
plugins: ["nativewind/babel"],
};
};

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,80 +0,0 @@
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<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const AudioTrackSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const audioStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
[item]
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected]
);
useEffect(() => {
const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
if (index !== undefined && index !== null) onChange(index);
}, []);
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedAudioSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -1,36 +0,0 @@
import { View, ViewProps } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
text?: string | number | null;
variant?: "gray" | "purple";
iconLeft?: React.ReactNode;
}
export const Badge: React.FC<Props> = ({
iconLeft,
text,
variant = "purple",
...props
}) => {
return (
<View
{...props}
className={`
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
${variant === "purple" && "bg-purple-600"}
${variant === "gray" && "bg-neutral-800"}
`}
>
{iconLeft && <View className="mr-1">{iconLeft}</View>}
<Text
className={`
text-xs
${variant === "purple" && "text-white"}
`}
>
{text}
</Text>
</View>
);
};

View File

@@ -1,87 +0,0 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
export type Bitrate = {
key: string;
value: number | undefined;
};
const BITRATES: Bitrate[] = [
{
key: "Max",
value: undefined,
},
{
key: "8 Mb/s",
value: 8000000,
},
{
key: "4 Mb/s",
value: 4000000,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "500 Kb/s",
value: 500000,
},
{
key: "250 Kb/s",
value: 250000,
},
];
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected: Bitrate;
}
export const BitrateSelector: React.FC<Props> = ({
onChange,
selected,
...props
}) => {
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{BITRATES.find((b) => b.value === selected.value)?.key}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{BITRATES?.map((b, index: number) => (
<DropdownMenu.Item
key={index.toString()}
onSelect={() => {
onChange(b);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -1,14 +1,13 @@
import * as Haptics from "expo-haptics";
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
import { TouchableOpacity, Text, ActivityIndicator, View } from "react-native";
import * as Haptics from "expo-haptics";
interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
onPress?: () => void;
className?: string;
textClassName?: string;
disabled?: boolean;
children?: string | ReactNode;
children?: string;
loading?: boolean;
color?: "purple" | "red" | "black";
iconRight?: ReactNode;
@@ -58,7 +57,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
{...props}
>
{loading ? (
<Loader />
<ActivityIndicator color={"white"} size={24} />
) : (
<View
className={`

View File

@@ -1,39 +1,35 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useEffect } from "react";
import { View } from "react-native";
import {
CastButton,
useCastDevice,
useDevices,
useRemoteMediaClient,
} from "react-native-google-cast";
import GoogleCast from "react-native-google-cast";
// import React, { useEffect } from "react";
// import {
// CastButton,
// useCastDevice,
// useDevices,
// useRemoteMediaClient,
// } from "react-native-google-cast";
// import GoogleCast from "react-native-google-cast";
type Props = {
width?: number;
height?: number;
item?: BaseItemDto | null;
startTimeTicks?: number | null;
};
export const Chromecast: React.FC<Props> = ({ width = 48, height = 48 }) => {
const client = useRemoteMediaClient();
const castDevice = useCastDevice();
const devices = useDevices();
const sessionManager = GoogleCast.getSessionManager();
const discoveryManager = GoogleCast.getDiscoveryManager();
export const Chromecast: React.FC<Props> = () => {
// const client = useRemoteMediaClient();
// const castDevice = useCastDevice();
// const devices = useDevices();
// const sessionManager = GoogleCast.getSessionManager();
// const discoveryManager = GoogleCast.getDiscoveryManager();
useEffect(() => {
(async () => {
if (!discoveryManager) {
return;
}
// useEffect(() => {
// (async () => {
// if (!discoveryManager) {
// return;
// }
await discoveryManager.startDiscovery();
})();
}, [client, devices, castDevice, sessionManager, discoveryManager]);
// await discoveryManager.startDiscovery();
// })();
// }, [client, devices, castDevice, sessionManager, discoveryManager]);
return (
<View className="rounded h-10 aspect-square flex items-center justify-center">
<CastButton style={{ tintColor: "white", height, width }} />
</View>
);
// return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
return <></>;
};

View File

@@ -9,12 +9,10 @@ import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
width?: number;
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
width = 176,
}) => {
const [api] = useAtom(apiAtom);
@@ -23,33 +21,23 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
getPrimaryImageUrl({
api,
item,
quality: 80,
quality: 70,
width: 300,
}),
[item]
[item],
);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
item.UserData?.PlayedPercentage || 0,
);
if (!url)
return (
<View
className="aspect-video border border-neutral-800"
style={{
width,
}}
></View>
<View className="w-48 aspect-video border border-neutral-800"></View>
);
return (
<View
style={{
width,
}}
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
>
<View className="w-48 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
<Image
key={item.Id}
id={item.Id}
@@ -73,7 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
style={{
width: `${progress}%`,
}}
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
className={`absolute bottom-0 left-0 h-1 bg-red-600 w-full`}
></View>
</>
)}

View File

@@ -1,382 +0,0 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Video from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
import * as FileSystem from "expo-file-system";
import {
FFmpegKit,
FFmpegKitConfig,
FFmpegSession,
ReturnCode,
} from "ffmpeg-kit-react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
export const CurrentlyPlayingBar: React.FC = () => {
const segments = useSegments();
const {
currentlyPlaying,
pauseVideo,
playVideo,
setCurrentlyPlayingState,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
} = usePlayback();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
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,
}),
};
});
const [streamUrl, setStreamUrl] = useState<string | null>(null);
const [ffmpegSession, setFfmpegSession] = useState<FFmpegSession | null>(
null
);
const startStreamingTranscode = async (inputUrl: string) => {
const outputDir = `${FileSystem.cacheDirectory}stream_${Date.now()}`;
const manifestPath = `${outputDir}/stream.m3u8`;
// Ensure the output directory exists
await FileSystem.makeDirectoryAsync(outputDir, { intermediates: true });
// Base FFmpeg command
let ffmpegCommand = `-i "${inputUrl}" `;
// Add hardware acceleration based on platform
if (Platform.OS === "android") {
ffmpegCommand += "-c:v h264_mediacodec "; // Hardware acceleration for Android
} else if (Platform.OS === "ios") {
ffmpegCommand += "-c:v h264_videotoolbox "; // Hardware acceleration for iOS
} else {
ffmpegCommand += "-c:v libx264 "; // Fallback to software encoding
}
// Complete the command
ffmpegCommand += `-c:a aac -f hls -hls_time 4 -hls_list_size 5 -hls_flags delete_segments "${manifestPath}"`;
console.log("FFmpeg command:", ffmpegCommand);
// Start FFmpeg process and return the session
return FFmpegKit.executeAsync(ffmpegCommand);
};
useEffect(() => {
const prepareStream = async () => {
if (currentlyPlaying?.url) {
try {
// Check if we already have a stream for this URL
const existingStream = await AsyncStorage.getItem(
currentlyPlaying.url
);
if (existingStream) {
setStreamUrl(existingStream);
} else {
const session = await startStreamingTranscode(currentlyPlaying.url);
setFfmpegSession(session);
const returnCode = await session.getReturnCode();
if (ReturnCode.isSuccess(returnCode)) {
console.log("Transcoding completed successfully");
const outputDir = `${
FileSystem.cacheDirectory
}stream_${Date.now()}`;
const manifestPath = `${outputDir}/stream.m3u8`;
setStreamUrl(manifestPath);
// Store the stream URL
await AsyncStorage.setItem(currentlyPlaying.url, manifestPath);
} else {
console.error("Transcoding failed");
// Handle failure (e.g., retry or show error message)
}
}
} catch (error) {
console.error("Error preparing stream:", error);
}
}
};
prepareStream();
return () => {
// Cleanup: cancel FFmpeg session when component unmounts
if (ffmpegSession) {
ffmpegSession.cancel();
}
};
}, [currentlyPlaying?.url]);
// Cleanup function
useEffect(() => {
return () => {
const cleanup = async () => {
if (streamUrl) {
try {
// Remove the stream URL from AsyncStorage
await AsyncStorage.removeItem(currentlyPlaying?.url || "");
// Delete the stream files
await FileSystem.deleteAsync(streamUrl.replace("file://", ""), {
idempotent: true,
});
} catch (error) {
console.error("Error cleaning up stream:", error);
}
}
};
cleanup();
};
}, [streamUrl, currentlyPlaying?.url]);
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 startPosition = useMemo(
() =>
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
? Math.round(
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
)
: 0,
[currentlyPlaying?.item]
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item: currentlyPlaying?.item,
quality: 70,
width: 200,
}),
[currentlyPlaying?.item, api]
);
if (!api || !currentlyPlaying) return null;
return (
<Animated.View
style={[animatedOuterStyle]}
className="absolute left-0 w-screen"
>
<BlurView
intensity={Platform.OS === "android" ? 60 : 100}
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
className={`h-full w-full rounded-xl overflow-hidden ${
Platform.OS === "android" && "bg-black"
}`}
>
<Animated.View
style={[
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
animatedInnerStyle,
]}
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
>
<View className="flex flex-row items-center space-x-4 shrink">
<TouchableOpacity
onPress={() => {
videoRef.current?.presentFullscreenPlayer();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
${
currentlyPlaying.item?.Type === "Audio"
? "aspect-square"
: "aspect-video"
}
`}
>
{streamUrl && (
<Video
ref={videoRef}
allowsExternalPlayback
style={{ width: "100%", height: "100%" }}
playWhenInactive={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
controls={false}
pictureInPicture={true}
poster={
backdropUrl && currentlyPlaying.item?.Type === "Audio"
? backdropUrl
: undefined
}
debug={{
enable: true,
thread: true,
}}
paused={!isPlaying}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
source={{
uri: streamUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
}}
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onFullscreenPlayerDidDismiss={() => {}}
onFullscreenPlayerDidPresent={() => {}}
onPlaybackStateChanged={(e) => {
if (e.isPlaying) {
setIsPlaying(true);
} else if (e.isSeeking) {
return;
} else {
setIsPlaying(false);
}
}}
progressUpdateInterval={2000}
onError={(e) => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e)
);
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
// setCurrentlyPlaying(null);
}}
renderLoader={
currentlyPlaying.item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<Loader />
</View>
)
}
/>
)}
</TouchableOpacity>
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
if (currentlyPlaying.item?.Type === "Audio")
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
else router.push(`/items/${currentlyPlaying.item?.Id}`);
}}
>
<Text>{currentlyPlaying.item?.Name}</Text>
</TouchableOpacity>
{currentlyPlaying.item?.Type === "Episode" && (
<TouchableOpacity
onPress={() => {
router.push(
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
);
}}
className="text-xs opacity-50"
>
<Text>{currentlyPlaying.item.SeriesName}</Text>
</TouchableOpacity>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<View>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
</View>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<TouchableOpacity
onPress={() => {
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
}}
>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
</TouchableOpacity>
)}
</View>
</View>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
if (isPlaying) pauseVideo();
else playVideo();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
{isPlaying ? (
<Ionicons name="pause" size={24} color="white" />
) : (
<Ionicons name="play" size={24} color="white" />
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
stopPlayback();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
</Animated.View>
</BlurView>
</Animated.View>
);
};

View File

@@ -1,233 +1,144 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
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 Ionicons from "@expo/vector-icons/Ionicons";
import {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
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 { useAtom } from "jotai";
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Loader } from "./Loader";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle";
import { DownloadQuality, useSettings } from "@/utils/atoms/settings";
import { useCallback } from "react";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
import { Text } from "./common/Text";
import { useDownloadMedia } from "@/hooks/useDownloadMedia";
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
interface DownloadProps extends TouchableOpacityProps {
type DownloadProps = {
item: BaseItemDto;
}
playbackURL: string;
};
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
export const DownloadItem: React.FC<DownloadProps> = ({
item,
playbackURL,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { downloadMedia, isDownloading, error, cancelDownload } =
useDownloadMedia(api, user?.Id);
const { startRemuxing } = useRemuxHlsToMp4(item);
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackURL, item);
const initiateDownload = useCallback(
async (qualitySetting: DownloadQuality) => {
if (!api || !user?.Id || !item.Id) {
throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item"
);
}
let deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
}
let maxStreamingBitrate: number | undefined = undefined;
if (qualitySetting === "high") {
maxStreamingBitrate = 8000000;
} else if (qualitySetting === "low") {
maxStreamingBitrate = 2000000;
}
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${item.Id}/PlaybackInfo`,
{
DeviceProfile: deviceProfile,
UserId: user.Id,
MaxStreamingBitrate: maxStreamingBitrate,
StartTimeTicks: 0,
EnableTranscoding: maxStreamingBitrate ? true : undefined,
AutoOpenLiveStream: true,
MediaSourceId: item.Id,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
}
);
let url: string | undefined = undefined;
const mediaSource = response.data.MediaSources?.[0] as MediaSourceInfo;
if (!mediaSource) {
throw new Error("No media source");
}
if (mediaSource.SupportsDirectPlay) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
url = `${api.basePath}/Videos/${item.Id}/stream.mp4?mediaSourceId=${item.Id}&static=true`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
UserId: user.Id,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
url = `${api.basePath}/Audio/${
item.Id
}/universal?${searchParams.toString()}`;
}
}
if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
} else {
throw new Error("No transcoding url");
}
return await startRemuxing(url);
},
[api, item, startRemuxing, user?.Id]
);
const { data: downloaded, isFetching } = useQuery({
queryKey: ["downloaded", item.Id],
queryFn: async () => {
if (!item.Id) return false;
const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
);
return data.some((d) => d.Id === item.Id);
},
enabled: !!item.Id,
const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
});
if (isFetching) {
const downloadFile = useCallback(async () => {
if (!playbackInfo) return;
const source = playbackInfo.MediaSources?.[0];
if (source?.SupportsDirectPlay && item.CanDownload) {
downloadMedia(item);
} else {
throw new Error(
"Direct play not supported thus the file cannot be downloaded",
);
}
}, [item, user, playbackInfo]);
const [downloaded, setDownloaded] = useState<boolean>(false);
useEffect(() => {
(async () => {
const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]",
);
if (data.find((d) => d.Id === item.Id)) setDownloaded(true);
})();
}, [process]);
if (isLoading) {
return <ActivityIndicator size={"small"} color={"white"} />;
}
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
return (
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Loader />
<View style={{ opacity: 0.5 }}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</View>
);
}
if (process && process?.item.Id === item.Id) {
if (process && process.item.Id !== item.Id!) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<TouchableOpacity onPress={() => {}} style={{ opacity: 0.5 }}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
</TouchableOpacity>
);
}
return (
<View>
{process ? (
<TouchableOpacity
onPress={() => {
cancelRemuxing();
}}
className="flex flex-row items-center"
>
{process.progress === 0 ? (
<Loader />
<ActivityIndicator size={"small"} color={"white"} />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
<View className="relative">
<View className="-rotate-45">
<ProgressCircle
size={28}
fill={process.progress}
width={4}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/>
</View>
<View className="absolute top-0 left-0 font-bold w-full h-full flex flex-col items-center justify-center">
<Text className="text-[7px]">
{process.progress.toFixed(0)}%
</Text>
</View>
</View>
)}
</View>
</TouchableOpacity>
);
}
if (queue.some((i) => i.id === item.Id)) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="hourglass" size={24} color="white" />
</View>
</TouchableOpacity>
);
}
if (downloaded) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</View>
</TouchableOpacity>
);
} else {
return (
<TouchableOpacity
onPress={() => {
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
// await startRemuxing(playbackUrl);
if (!settings?.downloadQuality?.value) {
throw new Error("No download quality selected");
}
await initiateDownload(settings?.downloadQuality?.value);
},
item,
});
}}
{...props}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
{process?.speed && process.speed > 0 ? (
<View className="ml-2">
<Text>{process.speed.toFixed(2)}x</Text>
</View>
) : null}
</TouchableOpacity>
) : downloaded ? (
<TouchableOpacity
onPress={() => {
router.push(
`/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`,
);
}}
>
<Ionicons name="cloud-download" size={26} color="#16a34a" />
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => {
// downloadFile();
startRemuxing();
}}
>
<Ionicons name="cloud-download-outline" size={26} color="white" />
</View>
</TouchableOpacity>
);
}
</TouchableOpacity>
)}
</View>
);
};

View File

@@ -2,7 +2,6 @@ import React from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
type ItemCardProps = {
item: BaseItemDto;
@@ -10,20 +9,24 @@ type ItemCardProps = {
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className="mt-2 flex flex-col h-12">
<View className="mt-2 flex flex-col grow-0">
{item.Type === "Episode" ? (
<>
<Text numberOfLines={2} className="">
{item.SeriesName}
</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
<Text className="">{item.SeriesName}</Text>
<Text
style={{ flexWrap: "wrap" }}
className="flex text-xs opacity-50 break-all"
>
{`S${item.SeasonName?.replace(
"Season ",
""
)}:E${item.IndexNumber?.toString()}`}{" "}
{item.Name}
</Text>
</>
) : (
<>
<Text numberOfLines={2}>{item.Name}</Text>
<Text>{item.Name}</Text>
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
</>
)}

View File

@@ -1,26 +1,22 @@
import { PropsWithChildren, ReactNode } from "react";
import { View, ViewProps } from "react-native";
import { View } from "react-native";
import { Text } from "./common/Text";
interface Props extends ViewProps {
type Props = {
title?: string | null | undefined;
subTitle?: string | null | undefined;
children?: ReactNode;
iconAfter?: ReactNode;
}
};
export const ListItem: React.FC<PropsWithChildren<Props>> = ({
title,
subTitle,
iconAfter,
children,
...props
}) => {
return (
<View
className="flex flex-row items-center justify-between bg-neutral-900 p-4"
{...props}
>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-bold ">{title}</Text>
{subTitle && <Text className="text-xs">{subTitle}</Text>}

View File

@@ -1,18 +0,0 @@
import {
ActivityIndicator,
ActivityIndicatorProps,
Platform,
View,
} from "react-native";
interface Props extends ActivityIndicatorProps {}
export const Loader: React.FC<Props> = ({ ...props }) => {
return (
<ActivityIndicator
size={"small"}
color={Platform.OS === "ios" ? "white" : "#9333ea"}
{...props}
/>
);
};

9
components/Loading.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { ActivityIndicator, View } from "react-native";
export const Loading: React.FC = () => {
return (
<View>
<ActivityIndicator />
</View>
);
};

View File

@@ -1,11 +1,11 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "@/components/WatchedIndicator";
import { WatchedIndicator } from "./WatchedIndicator";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
type MoviePosterProps = {
item: BaseItemDto;
@@ -23,40 +23,36 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
getPrimaryImageUrl({
api,
item,
width: 300,
}),
[item]
[item],
);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0
item.UserData?.PlayedPercentage || 0,
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.["Primary"] as string;
return item.ImageBlurHashes?.["Primary"]?.[key];
}, [item]);
if (!url)
return (
<View
className="rounded-md overflow-hidden border border-neutral-900"
style={{
aspectRatio: "10/15",
}}
></View>
);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
<WatchedIndicator item={item} />

View File

@@ -29,9 +29,15 @@ export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
uri: url,
isNetwork: false,
}}
controls
ref={videoRef}
onError={onError}
ignoreSilentSwitch="ignore"
resizeMode="contain"
reportBandwidth
style={{
width: "100%",
aspectRatio: 16 / 9,
}}
/>
);
};

View File

@@ -1,44 +0,0 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { tc } from "@/utils/textTools";
import { useState } from "react";
interface Props extends ViewProps {
text?: string | null;
characterLimit?: number;
}
export const OverviewText: React.FC<Props> = ({
text,
characterLimit = 140,
...props
}) => {
const [limit, setLimit] = useState(characterLimit);
if (!text) return null;
if (text.length > characterLimit)
return (
<TouchableOpacity
onPress={() =>
setLimit((prev) =>
prev === characterLimit ? text.length : characterLimit
)
}
{...props}
>
<View {...props} className="">
<Text>{tc(text, limit)}</Text>
<Text className="text-purple-600 mt-1">
{limit === characterLimit ? "Show more" : "Show less"}
</Text>
</View>
</TouchableOpacity>
);
return (
<View {...props}>
<Text>{text}</Text>
</View>
);
};

View File

@@ -9,7 +9,6 @@ import Animated, {
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
const HEADER_HEIGHT = 400;
@@ -33,14 +32,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1],
[2, 1, 1]
),
},
],
@@ -62,7 +61,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
onPress={() => router.back()}
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
style={{
top: inset.top + 17,
top: inset.top,
}}
>
<Ionicons
@@ -73,15 +72,6 @@ export const ParallaxScrollView: React.FC<Props> = ({
/>
</TouchableOpacity>
<View
className="absolute right-4 z-50 bg-black rounded-full p-0.5"
style={{
top: inset.top + 17,
}}
>
<Chromecast width={22} height={22} />
</View>
{logo && (
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
{logo}
@@ -99,9 +89,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
>
{headerImage}
</Animated.View>
<View className="flex-1 overflow-hidden bg-black pb-24">
{children}
</View>
<View className="flex-1 overflow-hidden bg-black">{children}</View>
</Animated.ScrollView>
</View>
);

View File

@@ -1,4 +1,5 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
@@ -28,7 +29,7 @@ const ParentPoster: React.FC<PosterProps> = ({ id }) => {
);
return (
<View className="rounded-lg overflow-hidden border border-neutral-900">
<View className="rounded-md overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}

View File

@@ -1,85 +0,0 @@
import { usePlayback } from "@/providers/PlaybackProvider";
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 CastContext, {
PlayServicesState,
useRemoteMediaClient,
} from "react-native-google-cast";
import { Button } from "./Button";
interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null;
url?: string | null;
}
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback();
const onPress = async () => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
async (selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
break;
case 1:
setCurrentlyPlayingState({ item, url });
break;
case cancelButtonIndex:
break;
}
}
);
};
return (
<Button
onPress={onPress}
iconRight={
<View className="flex flex-row items-center space-x-2">
<Ionicons name="play-circle" size={24} color="white" />
{client && <Feather name="cast" size={22} color="white" />}
</View>
}
{...props}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Button>
);
};

View File

@@ -6,7 +6,7 @@ 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 React, { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
@@ -15,15 +15,15 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const queryClient = useQueryClient();
const invalidateQueries = () => {
const invalidateQueries = useCallback(() => {
queryClient.invalidateQueries({
queryKey: ["item"],
queryKey: ["item", item.Id],
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
queryKey: ["resumeItems", user?.Id],
});
queryClient.invalidateQueries({
queryKey: ["nextUp"],
queryKey: ["nextUp", item.SeriesId],
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
@@ -31,10 +31,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
queryClient.invalidateQueries({
queryKey: ["seasons"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
};
}, [api, item.Id, queryClient, user?.Id]);
return (
<View>
@@ -50,9 +47,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
invalidateQueries();
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle" size={30} color="white" />
</View>
<Ionicons name="checkmark-circle" size={26} color="white" />
</TouchableOpacity>
) : (
<TouchableOpacity
@@ -66,9 +61,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
invalidateQueries();
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
</View>
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
</TouchableOpacity>
)}
</View>

View File

@@ -9,11 +9,10 @@ type PosterProps = {
item?: BaseItemDto | BaseItemPerson | null;
url?: string | null;
showProgress?: boolean;
blurhash?: string | null;
};
const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
if (!item)
const Poster: React.FC<PosterProps> = ({ item, url }) => {
if (!url || !item)
return (
<View
className="border border-neutral-900"
@@ -24,24 +23,13 @@ const Poster: React.FC<PosterProps> = ({ item, url, blurhash }) => {
);
return (
<View className="rounded-lg overflow-hidden border border-neutral-900">
<View className="rounded-md overflow-hidden border border-neutral-900">
<Image
placeholder={
blurhash
? {
blurhash,
}
: null
}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{

View File

@@ -1,41 +0,0 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View, ViewProps } from "react-native";
import { Badge } from "./Badge";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const Ratings: React.FC<Props> = ({ item }) => {
return (
<View className="flex flex-row items-center justify-center mt-2 space-x-2">
{item.OfficialRating && (
<Badge text={item.OfficialRating} variant="gray" />
)}
{item.CommunityRating && (
<Badge
text={item.CommunityRating}
variant="gray"
iconLeft={<Ionicons name="star" size={14} color="gold" />}
/>
)}
{item.CriticRating && (
<Badge
text={item.CriticRating}
variant="gray"
iconLeft={
<Image
source={require("@/assets/images/rotten-tomatoes.png")}
style={{
width: 14,
height: 14,
}}
/>
}
/>
)}
</View>
);
};

View File

@@ -1,15 +1,20 @@
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
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 { Text } from "./common/Text";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import ContinueWatchingPoster from "./ContinueWatchingPoster";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
import { Text } from "./common/Text";
import MoviePoster from "./MoviePoster";
import { useMemo } from "react";
type SimilarItemsProps = {
itemId: string;
@@ -45,7 +50,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
<Text className="px-4 text-2xl font-bold mb-2">Similar items</Text>
{isLoading ? (
<View className="my-12">
<Loader />
<ActivityIndicator />
</View>
) : (
<ScrollView horizontal>
@@ -53,7 +58,7 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({ itemId }) => {
{movies.map((item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}`)}
onPress={() => router.push(`/items/${item.Id}/page`)}
className="flex flex-col w-32"
>
<MoviePoster item={item} />

View File

@@ -1,98 +0,0 @@
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<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const subtitleStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter(
(x) => x.Type === "Subtitle"
) ?? [],
[item]
);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === 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 (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-10 rounded-xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{selectedSubtitleSteam
? tc(selectedSubtitleSteam?.DisplayTitle, 13)
: "None"}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
<DropdownMenu.Item
key={"-1"}
onSelect={() => {
onChange(-1);
}}
>
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

288
components/VideoPlayer.tsx Normal file
View File

@@ -0,0 +1,288 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Feather, Ionicons } from "@expo/vector-icons";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { TouchableOpacity, View } from "react-native";
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Button } from "./Button";
import { Text } from "./common/Text";
import ios12 from "../utils/profiles/ios12";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
type VideoPlayerProps = {
itemId: string;
onChangePlaybackURL: (url: string | null) => void;
};
const BITRATES = [
{
key: "Max",
value: undefined,
},
{
key: "4 Mb/s",
value: 4000000,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "500 Kb/s",
value: 500000,
},
];
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
itemId,
onChangePlaybackURL,
}) => {
const videoRef = useRef<VideoRef | null>(null);
const [maxBitrate, setMaxbitrate] = useState<number | undefined>(undefined);
const [paused, setPaused] = useState(true);
const [progress, setProgress] = useState(0);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const client = useRemoteMediaClient();
const queryClient = useQueryClient();
const { data: item } = useQuery({
queryKey: ["item", itemId],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId,
}),
enabled: !!itemId && !!api,
staleTime: 60,
});
const { data: sessionData } = useQuery({
queryKey: ["sessionData", itemId],
queryFn: async () => {
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!itemId && !!api && !!user?.Id,
staleTime: 0,
});
const { data: playbackURL } = useQuery({
queryKey: ["playbackUrl", itemId, maxBitrate, castDevice],
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,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
});
onChangePlaybackURL(url);
return url;
},
enabled: !!sessionData,
staleTime: 0,
});
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (!currentTime || !sessionData?.PlaySessionId || paused) return;
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, item, api, paused],
);
const play = () => {
if (videoRef.current) {
videoRef.current.resume();
setPaused(false);
}
};
const pause = useCallback(() => {
videoRef.current?.pause();
setPaused(true);
if (progress > 0)
reportPlaybackStopped({
api,
itemId: item?.Id,
positionTicks: progress,
sessionId: sessionData?.PlaySessionId,
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
}, [api, item, progress, sessionData, queryClient]);
const startPosition = useMemo(
() =>
item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0,
[item],
);
const enableVideo = useMemo(
() => !!(playbackURL && item && startPosition !== null && sessionData),
[playbackURL, item, startPosition, sessionData],
);
const chromecastReady = useMemo(
() => !!(castDevice?.deviceId && item),
[castDevice, item],
);
const cast = useCallback(() => {
if (!client || !playbackURL || !item) return;
client.loadMedia({
mediaInfo: {
contentUrl: playbackURL,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
streamDuration: Math.floor((item.RunTimeTicks || 0) / 10000),
},
startTime: Math.floor(
(item.UserData?.PlaybackPositionTicks || 0) / 10000,
),
});
}, [item, client, playbackURL]);
useEffect(() => {
videoRef.current?.pause();
}, []);
return (
<View>
{enableVideo === true && startPosition !== null && !!playbackURL ? (
<Video
style={{ width: 0, height: 0 }}
source={{
uri: playbackURL,
isNetwork: true,
startPosition,
}}
ref={videoRef}
onBuffer={(e) => (e.isBuffering ? console.log("Buffering...") : null)}
onProgress={(e) => onProgress(e)}
onFullscreenPlayerDidDismiss={() => {
pause();
}}
onFullscreenPlayerDidPresent={() => {
play();
}}
paused={paused}
ignoreSilentSwitch="ignore"
/>
) : null}
<View className="flex flex-row items-center justify-between">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>
{BITRATES.find((b) => b.value === maxBitrate)?.key}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
{BITRATES?.map((b: any, index: number) => (
<DropdownMenu.Item
key={index.toString()}
onSelect={() => {
setMaxbitrate(b.value);
}}
>
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col w-full">
<Button
disabled={!enableVideo}
onPress={() => {
if (chromecastReady) {
cast();
} else {
setTimeout(() => {
if (!videoRef.current) return;
videoRef.current.presentFullscreenPlayer();
}, 1000);
}
}}
iconRight={
chromecastReady ? (
<Feather name="cast" size={20} color="white" />
) : (
<Ionicons name="play-circle" size={24} color="white" />
)
}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Button>
</View>
</View>
);
};

View File

@@ -1,12 +0,0 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
interface Props extends ViewProps {}
export const TitleHeader: React.FC<Props> = ({ ...props }) => {
return (
<View {...props}>
<Text></Text>
</View>
);
};

View File

@@ -1,42 +0,0 @@
import { useMemo } from "react";
import { StyleSheet, View, ViewProps } from "react-native";
const getItemStyle = (index: number, numColumns: number) => {
const alignItems = (() => {
if (numColumns < 2 || index % numColumns === 0) return "flex-start";
if ((index + 1) % numColumns === 0) return "flex-end";
return "center";
})();
return {
padding: 20,
alignItems,
width: "100%",
} as const;
};
type ColumnItemProps = ViewProps & {
children: React.ReactNode;
index: number;
numColumns: number;
};
export const ColumnItem = ({
children,
index,
numColumns,
...rest
}: ColumnItemProps) => {
return (
<View className="flex flex-col mb-2 p-4" style={{ width: "33.3%" }}>
<View
className={`
`}
{...rest}
>
{children}
</View>
</View>
);
};

View File

@@ -1,95 +1,56 @@
import { FlashList, FlashListProps } from "@shopify/flash-list";
import React, { useEffect } from "react";
import { View, ViewStyle } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { Loader } from "../Loader";
import { Text } from "./Text";
import React from "react";
import {
ScrollView,
View,
ViewStyle,
ActivityIndicator,
ScrollViewProps,
} from "react-native";
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface HorizontalScrollProps<T>
extends PartialExcept<
Omit<FlashListProps<T>, "renderItem">,
"estimatedItemSize"
> {
interface HorizontalScrollProps<T> extends ScrollViewProps {
data?: T[] | null;
renderItem: (item: T, index: number) => React.ReactNode;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number;
loading?: boolean;
}
export function HorizontalScroll<T>({
data = [],
data,
renderItem,
containerStyle,
contentContainerStyle,
loadingContainerStyle,
loading = false,
height = 164,
...props
}: HorizontalScrollProps<T>): React.ReactElement {
const animatedOpacity = useSharedValue(0);
const animatedStyle1 = useAnimatedStyle(() => {
return {
opacity: withTiming(animatedOpacity.value, { duration: 250 }),
};
});
useEffect(() => {
if (data) {
animatedOpacity.value = 1;
}
}, [data]);
if (data === undefined || data === null || loading) {
if (!data) {
return (
<View
style={[
{
flex: 1,
justifyContent: "center",
alignItems: "center",
},
{ flex: 1, justifyContent: "center", alignItems: "center" },
loadingContainerStyle,
]}
>
<Loader />
<ActivityIndicator size="small" />
</View>
);
}
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => (
<View className="mr-2">
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
);
return (
<Animated.View style={[containerStyle, animatedStyle1]}>
<FlashList
data={data}
renderItem={renderFlashListItem}
horizontal
estimatedItemSize={100}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 16,
...contentContainerStyle,
}}
ListEmptyComponent={() => (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
<ScrollView
horizontal
style={containerStyle}
contentContainerStyle={contentContainerStyle}
showsHorizontalScrollIndicator={false}
{...props}
>
<View className="flex flex-row px-4">
{data.map((item, index) => (
<View className="mr-2" key={index}>
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
)}
{...props}
/>
</Animated.View>
))}
</View>
</ScrollView>
);
}

View File

@@ -1,146 +0,0 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
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,
withTiming,
} from "react-native-reanimated";
import { Loader } from "../Loader";
import { Text } from "./Text";
interface HorizontalScrollProps
extends Omit<FlashListProps<BaseItemDto>, "renderItem" | "data" | "style"> {
queryFn: ({
pageParam,
}: {
pageParam: number;
}) => Promise<BaseItemDtoQueryResult | null>;
queryKey: string[];
initialData?: BaseItemDto[];
renderItem: (item: BaseItemDto, index: number) => React.ReactNode;
containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number;
loading?: boolean;
}
export function InfiniteHorizontalScroll({
queryFn,
queryKey,
initialData = [],
renderItem,
containerStyle,
contentContainerStyle,
loadingContainerStyle,
loading = false,
height = 164,
...props
}: HorizontalScrollProps): React.ReactElement {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const animatedOpacity = useSharedValue(0);
const animatedStyle1 = useAnimatedStyle(() => {
return {
opacity: withTiming(animatedOpacity.value, { duration: 250 }),
};
});
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey,
queryFn,
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,
});
const flatData = useMemo(() => {
return (
(data?.pages.flatMap((p) => p?.Items).filter(Boolean) as BaseItemDto[]) ||
[]
);
}, [data]);
useEffect(() => {
if (data) {
animatedOpacity.value = 1;
}
}, [data]);
if (data === undefined || data === null || loading) {
return (
<View
style={[
{
flex: 1,
justifyContent: "center",
alignItems: "center",
},
loadingContainerStyle,
]}
>
<Loader />
</View>
);
}
return (
<Animated.View style={[containerStyle, animatedStyle1]}>
<FlashList
data={flatData}
renderItem={({ item, index }) => (
<View className="mr-2">
<React.Fragment>{renderItem(item, index)}</React.Fragment>
</View>
)}
estimatedItemSize={height}
horizontal
onEndReached={() => {
if (hasNextPage) {
fetchNextPage();
}
}}
onEndReachedThreshold={0.5}
contentContainerStyle={{
paddingHorizontal: 16,
...contentContainerStyle,
}}
showsHorizontalScrollIndicator={false}
ListEmptyComponent={
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
</View>
}
{...props}
/>
</Animated.View>
);
}

View File

@@ -1,18 +1,15 @@
import React from "react";
import { TextInput, TextInputProps } from "react-native";
import { TextInputProps, TextProps } from "react-native";
import { TextInput } from "react-native";
export function Input(props: TextInputProps) {
const { style, ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null);
return (
<TextInput
ref={inputRef}
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
allowFontScaling={false}
style={[{ color: "white" }, style]}
{...otherProps}
placeholderTextColor={"#9CA3AF"}
clearButtonMode="while-editing"
/>
);
}

View File

@@ -1,65 +0,0 @@
import {
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { PropsWithChildren } from "react";
import { useRouter } from "expo-router";
import * as Haptics from "expo-haptics";
interface Props extends TouchableOpacityProps {
item: BaseItemDto;
}
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item,
children,
...props
}) => {
const router = useRouter();
return (
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
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;
}
if (item.Type === "Person") {
router.push(`/actors/${item.Id}`);
return;
}
if (item.Type === "BoxSet") {
router.push(`/collections/${item.Id}`);
return;
}
router.push(`/items/${item.Id}`);
}}
{...props}
>
{children}
</TouchableOpacity>
);
};

View File

@@ -1,29 +0,0 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
interface Props extends ViewProps {
index: number;
}
export const VerticalSkeleton: React.FC<Props> = ({ index, ...props }) => {
return (
<View
key={index}
style={{
width: "32%",
}}
className="flex flex-col"
{...props}
>
<View
style={{
aspectRatio: "10/15",
}}
className="w-full bg-neutral-800 mb-2 rounded-lg"
></View>
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
<View className="h-2 bg-neutral-800 rounded-full mb-1"></View>
<View className="h-2 bg-neutral-800 rounded-full mb-2 w-1/2"></View>
</View>
);
};

View File

@@ -1,84 +1,97 @@
import React, { useCallback } from "react";
import { TouchableOpacity } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { TouchableOpacity } from "react-native";
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 { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
import * as Haptics from "expo-haptics";
import { useRef, useMemo, useState } from "react";
import Video, { VideoRef } from "react-native-video";
import * as FileSystem from "expo-file-system";
interface EpisodeCardProps {
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<EpisodeCardProps> = ({ item }) => {
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles();
const videoRef = useRef<VideoRef | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const { setCurrentlyPlayingState } = usePlayback();
const openFile = () => {
videoRef.current?.presentFullscreenPlayer();
};
const handleOpenFile = useCallback(async () => {
setCurrentlyPlayingState({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, setCurrentlyPlayingState]);
const fileUrl = useMemo(() => {
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
}, [item]);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}, [deleteFile, item.Id]);
const contextMenuOptions = [
const options = [
{
label: "Delete",
onSelect: handleDeleteFile,
onSelect: (id: string) => {
deleteFile(id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
destructive: true,
},
];
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={handleOpenFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className="font-bold">{item.Name}</Text>
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions
collisionPadding={10}
loop={false}
>
{contextMenuOptions.map((option) => (
<ContextMenu.Item
key={option.label}
onSelect={option.onSelect}
destructive={option.destructive}
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={openFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<ContextMenu.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<Text className=" font-bold">{item.Name}</Text>
<Text className=" text-xs opacity-50">
Episode {item.IndexNumber}
</Text>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions
collisionPadding={10}
loop={false}
>
{options.map((i) => (
<ContextMenu.Item
onSelect={() => {
i.onSelect(item.Id!);
}}
key={i.label}
destructive={i.destructive}
>
<ContextMenu.ItemTitle
style={{
color: "red",
}}
>
{i.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<Video
style={{ width: 0, height: 0 }}
source={{
uri: fileUrl,
isNetwork: false,
}}
controls
onFullscreenPlayerDidDismiss={() => {
setIsPlaying(false);
videoRef.current?.pause();
}}
onFullscreenPlayerDidPresent={() => {
setIsPlaying(true);
videoRef.current?.resume();
}}
ref={videoRef}
resizeMode="contain"
paused={!isPlaying}
/>
</>
);
};

View File

@@ -1,92 +1,107 @@
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runtimeTicksToMinutes } from "@/utils/time";
import * as ContextMenu from "zeego/context-menu";
import { router } from "expo-router";
import { useFiles } from "@/hooks/useFiles";
import Video, {
OnBufferData,
OnPlaybackStateChangedData,
OnProgressData,
OnVideoErrorData,
VideoRef,
} from "react-native-video";
import * as FileSystem from "expo-file-system";
import { useMemo, useRef, useState } from "react";
import * as Haptics from "expo-haptics";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
interface MovieCardProps {
item: BaseItemDto;
}
/**
* MovieCard component displays a movie with context menu options.
* @param {MovieCardProps} props - The component props.
* @returns {React.ReactElement} The rendered MovieCard component.
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles();
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const { setCurrentlyPlayingState } = usePlayback();
const openFile = () => {
videoRef.current?.presentFullscreenPlayer();
};
const handleOpenFile = useCallback(() => {
setCurrentlyPlayingState({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, setCurrentlyPlayingState]);
const fileUrl = useMemo(() => {
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
}, [item]);
/**
* Handles deleting the file with haptic feedback.
*/
const handleDeleteFile = useCallback(() => {
if (item.Id) {
deleteFile(item.Id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
}, [deleteFile, item.Id]);
const contextMenuOptions = [
const options = [
{
label: "Delete",
onSelect: handleDeleteFile,
onSelect: (id: string) => {
deleteFile(id);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
destructive: true,
},
];
return (
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={handleOpenFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className="font-bold">{item.Name}</Text>
<View className="flex flex-col">
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
<Text className="text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
</View>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
loop={false}
alignOffset={0}
avoidCollisions={false}
collisionPadding={0}
>
{contextMenuOptions.map((option) => (
<ContextMenu.Item
key={option.label}
onSelect={option.onSelect}
destructive={option.destructive}
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<TouchableOpacity
onPress={openFile}
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<ContextMenu.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<Text className=" font-bold">{item.Name}</Text>
<View className="flex flex-row items-center justify-between">
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
<Text className=" text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
</View>
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
alignOffset={0}
avoidCollisions={false}
collisionPadding={0}
loop={false}
>
{options.map((i) => (
<ContextMenu.Item
onSelect={() => {
i.onSelect(item.Id!);
}}
key={i.label}
destructive={i.destructive}
>
<ContextMenu.ItemTitle
style={{
color: "red",
}}
>
{i.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<Video
style={{ width: 0, height: 0 }}
source={{
uri: fileUrl,
isNetwork: false,
}}
controls
onFullscreenPlayerDidDismiss={() => {
setIsPlaying(false);
videoRef.current?.pause();
}}
onFullscreenPlayerDidPresent={() => {
setIsPlaying(true);
videoRef.current?.resume();
}}
ref={videoRef}
resizeMode="contain"
paused={!isPlaying}
/>
</>
);
};

View File

@@ -1,99 +0,0 @@
import { Text } from "@/components/common/Text";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";
interface FilterButtonProps<T> extends ViewProps {
collectionId: string;
showSearch?: boolean;
queryKey: string;
values: T[];
title: string;
set: (value: T[]) => void;
queryFn: (params: any) => Promise<any>;
searchFilter: (item: T, query: string) => boolean;
renderItemLabel: (item: T) => React.ReactNode;
icon?: "filter" | "sort";
}
export const FilterButton = <T,>({
collectionId,
queryFn,
queryKey,
set,
values,
title,
renderItemLabel,
searchFilter,
showSearch = true,
icon = "filter",
...props
}: FilterButtonProps<T>) => {
const [open, setOpen] = useState(false);
const { data: filters } = useQuery<T[]>({
queryKey: ["filters", title, queryKey, collectionId],
queryFn,
staleTime: 0,
enabled: !!collectionId && !!queryFn && !!queryKey,
});
return (
<>
<TouchableOpacity
onPress={() => {
filters?.length && setOpen(true);
}}
>
<View
className={`
px-3 py-1.5 rounded-full flex flex-row items-center space-x-1
${
values.length > 0
? "bg-purple-600 border border-purple-700"
: "bg-neutral-900 border border-neutral-900"
}
${filters?.length === 0 && "opacity-50"}
`}
{...props}
>
<Text
className={`
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
text-xs font-semibold`}
>
{title}
</Text>
{icon === "filter" ? (
<Ionicons
name="filter"
size={14}
color="white"
style={{ opacity: 0.5 }}
/>
) : (
<FontAwesome
name="sort"
size={14}
color="white"
style={{ opacity: 0.5 }}
/>
)}
</View>
</TouchableOpacity>
<FilterSheet<T>
title={title}
open={open}
setOpen={setOpen}
data={filters}
values={values}
set={set}
renderItemLabel={renderItemLabel}
searchFilter={searchFilter}
showSearch={showSearch}
/>
</>
);
};

View File

@@ -1,217 +0,0 @@
import {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetFlatList,
BottomSheetModal,
BottomSheetScrollView,
BottomSheetView,
} from "@gorhom/bottom-sheet";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Text } from "@/components/common/Text";
import { StyleSheet, TouchableOpacity, View, ViewProps } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Button } from "../Button";
import { Input } from "../common/Input";
interface Props<T> extends ViewProps {
open: boolean;
setOpen: (open: boolean) => void;
data?: T[] | null;
values: T[];
set: (value: T[]) => void;
title: string;
searchFilter: (item: T, query: string) => boolean;
renderItemLabel: (item: T) => React.ReactNode;
showSearch?: boolean;
}
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 = <T,>({
values,
data: _data,
open,
set,
setOpen,
title,
searchFilter,
renderItemLabel,
showSearch = true,
...props
}: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["80%"], []);
const [data, setData] = useState<T[]>([]);
const [offset, setOffset] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const filteredData = useMemo(() => {
if (!search) return _data;
const results = [];
for (let i = 0; i < (_data?.length || 0); i++) {
if (_data && searchFilter(_data[i], search)) {
results.push(_data[i]);
}
}
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);
for (let i = offset; i < Math.min(_data.length, offset + LIMIT); i++) {
tmp.add(_data[i]);
}
setData(Array.from(tmp));
}, [offset, _data]);
useEffect(() => {
if (open) bottomSheetModalRef.current?.present();
else bottomSheetModalRef.current?.dismiss();
}, [open]);
const handleSheetChanges = useCallback((index: number) => {
if (index === -1) {
setOpen(false);
}
}, []);
const renderData = useMemo(() => {
if (search.length > 0 && showSearch) return filteredData;
return data;
}, [search, filteredData, data]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
/>
),
[]
);
return (
<BottomSheetModal
ref={bottomSheetModalRef}
index={0}
snapPoints={snapPoints}
onChange={handleSheetChanges}
backdropComponent={renderBackdrop}
handleIndicatorStyle={{
backgroundColor: "white",
}}
backgroundStyle={{
backgroundColor: "#171717",
}}
style={{}}
>
<BottomSheetScrollView
style={{
flex: 1,
}}
>
<View className="px-4 mt-2 mb-8">
<Text className="font-bold text-2xl">{title}</Text>
<Text className="mb-2 text-neutral-500">{_data?.length} items</Text>
{showSearch && (
<Input
placeholder="Search..."
className="my-2"
value={search}
onChangeText={(text) => {
setSearch(text);
}}
returnKeyType="done"
/>
)}
<View
style={{
borderRadius: 20,
overflow: "hidden",
}}
className="mb-4 flex flex-col rounded-xl overflow-hidden"
>
{renderData?.map((item, index) => (
<View key={index}>
<TouchableOpacity
onPress={() => {
if (!values.includes(item)) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
>
<Text>{renderItemLabel(item)}</Text>
{values.includes(item) ? (
<Ionicons name="radio-button-on" size={24} color="white" />
) : (
<Ionicons name="radio-button-off" size={24} color="white" />
)}
</TouchableOpacity>
<View
style={{
height: StyleSheet.hairlineWidth,
}}
className="h-1 divide-neutral-700 "
></View>
</View>
))}
</View>
{data.length < (_data?.length || 0) && (
<Button
onPress={() => {
setOffset(offset + 100);
}}
>
Load more
</Button>
)}
</View>
</BottomSheetScrollView>
</BottomSheetModal>
);
};

View File

@@ -1,38 +0,0 @@
import {
genreFilterAtom,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import { Ionicons } from "@expo/vector-icons";
import { useAtom } from "jotai";
import { TouchableOpacity, TouchableOpacityProps } from "react-native";
interface Props extends TouchableOpacityProps {}
export const ResetFiltersButton: React.FC<Props> = ({ ...props }) => {
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
if (
selectedGenres.length === 0 &&
selectedTags.length === 0 &&
selectedYears.length === 0
) {
return null;
}
return (
<TouchableOpacity
onPress={() => {
setSelectedGenres([]);
setSelectedTags([]);
setSelectedYears([]);
}}
className="bg-purple-600 rounded-full w-[30px] h-[30px] flex items-center justify-center mr-1"
{...props}
>
<Ionicons name="close" size={20} color="white" />
</TouchableOpacity>
);
};

View File

@@ -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<Props> = ({ title, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity>
<View
className={`
px-3 py-2 rounded-full flex flex-row items-center space-x-2 bg-neutral-900
`}
{...props}
>
<Text>Sort by</Text>
<Ionicons
name="filter"
size={16}
color="white"
style={{ opacity: 0.5 }}
/>
</View>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
{sortOptions?.map((g) => (
<DropdownMenu.CheckboxItem
value={sortBy.key === g.key ? "on" : "off"}
onValueChange={(next, previous) => {
if (next === "on") {
setSortBy(g);
} else {
setSortBy(sortOptions[0]);
}
}}
key={g.key}
textValue={g.value}
>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
<DropdownMenu.Separator />
<DropdownMenu.Group>
{sortOrderOptions.map((g) => (
<DropdownMenu.CheckboxItem
value={sortOrder.key === g.key ? "on" : "off"}
onValueChange={(next, previous) => {
if (next === "on") {
setSortOrder(g);
} else {
setSortOrder(sortOrderOptions[0]);
}
}}
key={g.key}
textValue={g.value}
>
<DropdownMenu.ItemIndicator />
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

View File

@@ -1,177 +0,0 @@
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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } 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 Carousel, {
ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
interface Props extends ViewProps {}
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const router = useRouter();
const queryClient = useQueryClient();
const [settings] = useSettings();
const ref = React.useRef<ICarouselInstance>(null);
const progress = useSharedValue<number>(0);
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: 0,
});
const onPressPagination = (index: number) => {
ref.current?.scrollTo({
/**
* Calculate the difference between the current index and the target index
* to ensure that the carousel scrolls to the nearest index
*/
count: index - progress.value,
animated: true,
});
};
const { data: popularItems, isFetching: l2 } = useQuery<BaseItemDto[]>({
queryKey: ["popular", user?.Id],
queryFn: async () => {
if (!api || !user?.Id || !sf_carousel) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: sf_carousel,
limit: 10,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!sf_carousel,
staleTime: 0,
});
const width = Dimensions.get("screen").width;
if (l1 || l2)
return (
<View className="h-[242px] flex items-center justify-center">
<Loader />
</View>
);
if (!popularItems) return null;
return (
<View className="flex flex-col items-center" {...props}>
<Carousel
autoPlay={true}
autoPlayInterval={3000}
loop={true}
ref={ref}
width={width}
height={204}
data={popularItems}
onProgressChange={progress}
renderItem={({ item, index }) => <RenderItem item={item} />}
/>
<Pagination.Basic
progress={progress}
data={popularItems}
dotStyle={{
backgroundColor: "rgba(255,255,255,0.2)",
borderRadius: 50,
}}
activeDotStyle={{
backgroundColor: "rgba(255,255,255,0.8)",
borderRadius: 50,
}}
containerStyle={{ gap: 5, marginTop: 12 }}
onPress={onPressPagination}
/>
</View>
);
};
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const screenWidth = Dimensions.get("screen").width;
const uri = useMemo(() => {
if (!api) return null;
return getBackdropUrl({
api,
item,
quality: 70,
width: Math.floor(screenWidth * 0.8 * 2),
});
}, [api, item]);
const logoUri = useMemo(() => {
if (!api) return null;
return getLogoImageUrlById({ api, item, height: 100 });
}, [item]);
if (!uri || !logoUri) return null;
return (
<TouchableItemRouter item={item}>
<View className="px-4">
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
<Image
source={{
uri,
}}
style={{
width: "100%",
height: 200,
borderRadius: 16,
overflow: "hidden",
}}
/>
<View className="absolute bottom-0 left-0 w-full h-24 p-4 flex items-center">
<Image
source={{
uri: logoUri,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "contain",
}}
/>
</View>
</View>
</View>
</TouchableItemRouter>
);
};

View File

@@ -1,60 +0,0 @@
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 ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
interface Props extends ViewProps {
title: string;
loading?: boolean;
orientation?: "horizontal" | "vertical";
data?: BaseItemDto[] | null;
height?: "small" | "large";
disabled?: boolean;
}
export const ScrollingCollectionList: React.FC<Props> = ({
title,
data,
orientation = "vertical",
height = "small",
loading = false,
disabled = false,
...props
}) => {
if (disabled) return null;
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
{title}
</Text>
<HorizontalScroll<BaseItemDto>
data={data}
height={orientation === "vertical" ? 247 : 164}
loading={loading}
renderItem={(item, index) => (
<TouchableItemRouter
key={index}
item={item}
className={`flex flex-col
${orientation === "vertical" ? "w-28" : "w-44"}
`}
>
<View>
{orientation === "vertical" ? (
<MoviePoster item={item} />
) : (
<ContinueWatchingPoster item={item} />
)}
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/>
</View>
);
};

View File

@@ -1,72 +0,0 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
BaseItemDto,
BaseItemDtoQueryResult,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
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";
interface Props extends ViewProps {
collection: BaseItemDto;
}
export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const fetchItems = useCallback(
async ({
pageParam,
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: collection.Id,
startIndex: pageParam,
limit: 8,
});
return response.data;
},
[api, user?.Id, collection.Id]
);
if (!collection) return null;
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
{collection.Name}
</Text>
<InfiniteHorizontalScroll
height={247}
renderItem={(item, index) => (
<TouchableItemRouter
key={index}
item={item}
className={`flex flex-col
${"w-28"}
`}
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
queryFn={fetchItems}
queryKey={["media-list", collection.Id!]}
/>
</View>
);
};

View File

@@ -1,17 +0,0 @@
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";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<View className="flex flex-row items-center self-center px-4" {...props}>
<Text className="text-center font-bold text-2xl mr-2">{item?.Name}</Text>
</View>
);
};

View File

@@ -1,35 +0,0 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useRouter } from "expo-router";
import { View, ViewProps } from "react-native";
import { SongsListItem } from "./SongsListItem";
interface Props extends ViewProps {
songs?: BaseItemDto[] | null;
collectionId: string;
artistId: string;
albumId: string;
}
export const SongsList: React.FC<Props> = ({
collectionId,
artistId,
albumId,
songs = [],
...props
}) => {
const router = useRouter();
return (
<View className="flex flex-col space-y-2" {...props}>
{songs?.map((item: BaseItemDto, index: number) => (
<SongsListItem
key={item.Id}
item={item}
index={index}
collectionId={collectionId}
artistId={artistId}
albumId={albumId}
/>
))}
</View>
);
};

View File

@@ -1,139 +0,0 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios from "@/utils/profiles/ios";
import { runtimeTicksToSeconds } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
interface Props extends TouchableOpacityProps {
collectionId: string;
artistId: string;
albumId: string;
item: BaseItemDto;
index: number;
}
export const SongsListItem: React.FC<Props> = ({
collectionId,
artistId,
albumId,
item,
index,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const { setCurrentlyPlayingState } = usePlayback();
const openSelect = () => {
if (!castDevice?.deviceId) {
play("device");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
(selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
play("cast");
break;
case 1:
play("device");
break;
case cancelButtonIndex:
break;
}
}
);
};
const play = async (type: "device" | "cast") => {
if (!user?.Id || !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,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
});
if (!url || !item) return;
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: url,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
startTime: 0,
});
}
});
} else {
setCurrentlyPlayingState({
item,
url,
});
}
};
return (
<TouchableOpacity
onPress={() => {
openSelect();
}}
{...props}
>
<View className="flex flex-row items-center space-x-4 bg-neutral-900 border-neutral-800 px-4 py-4 rounded-xl">
<Text className="opacity-50">{index + 1}</Text>
<View>
<Text className="mb-0.5 font-semibold">{item.Name}</Text>
<Text className="opacity-50 text-xs">
{runtimeTicksToSeconds(item.RunTimeTicks)}
</Text>
</View>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,12 +1,9 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import Ionicons from "@expo/vector-icons/Ionicons";
import { type IconProps } from "@expo/vector-icons/build/createIconSet";
import { type ComponentProps } from "react";
import Ionicons from '@expo/vector-icons/Ionicons';
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
import { type ComponentProps } from 'react';
export function TabBarIcon({
style,
...rest
}: IconProps<ComponentProps<typeof Ionicons>["name"]>) {
return <Ionicons size={26} style={[{ marginBottom: -3 }, style]} {...rest} />;
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

View File

@@ -1,82 +0,0 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
type ArtistPosterProps = {
item?: BaseItemDto | null;
id?: string | null;
showProgress?: boolean;
};
const AlbumCover: React.FC<ArtistPosterProps> = ({ item, id }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(() => {
const u = getPrimaryImageUrl({
api,
item,
});
return u;
}, [item]);
const url2 = useMemo(() => {
const u = getPrimaryImageUrlById({
api,
id,
quality: 85,
width: 300,
});
return u;
}, [item]);
if (!item && id)
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<Image
key={id}
id={id}
source={
url2
? {
uri: url2,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
if (item)
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default AlbumCover;

View File

@@ -1,57 +0,0 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { View } from "react-native";
type ArtistPosterProps = {
item: BaseItemDto;
showProgress?: boolean;
};
const ArtistPoster: React.FC<ArtistPosterProps> = ({
item,
showProgress = false,
}) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item,
}),
[item]
);
if (!url)
return (
<View
className="rounded-lg overflow-hidden border border-neutral-900"
style={{
aspectRatio: "1/1",
}}
></View>
);
return (
<View className="relative rounded-md overflow-hidden border border-neutral-900">
<Image
key={item.Id}
id={item.Id}
source={{
uri: url,
}}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "1/1",
}}
/>
</View>
);
};
export default ArtistPoster;

View File

@@ -1,58 +0,0 @@
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
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;
showProgress?: boolean;
};
const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
const [api] = useAtom(apiAtom);
const url = useMemo(
() =>
getPrimaryImageUrl({
api,
item,
}),
[item]
);
const blurhash = useMemo(() => {
const key = item.ImageTags?.["Primary"] as string;
return item.ImageBlurHashes?.["Primary"]?.[key];
}, [item]);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<Image
placeholder={{
blurhash,
}}
key={item.Id}
id={item.Id}
source={
url
? {
uri: url,
}
: null
}
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
width: "100%",
}}
/>
</View>
);
};
export default SeriesPoster;

View File

@@ -6,20 +6,15 @@ import React from "react";
import { Linking, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import Poster from "../posters/Poster";
import Poster from "../Poster";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { router, usePathname } from "expo-router";
import { useSettings } from "@/utils/atoms/settings";
import { router } from "expo-router";
export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
const [api] = useAtom(apiAtom);
const [settings] = useSettings();
const pathname = usePathname();
return (
<View>
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
@@ -28,7 +23,7 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/actors/${item.Id}`);
// TODO: Navigate to person
}}
key={item.Id}
className="flex flex-col w-32"

View File

@@ -4,7 +4,7 @@ import { router } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View } from "react-native";
import Poster from "../posters/Poster";
import Poster from "../Poster";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import { getPrimaryImageUrlById } from "@/utils/jellyfin/image/getPrimaryImageUrlById";
@@ -20,7 +20,7 @@ export const CurrentSeries = ({ item }: { item: BaseItemDto }) => {
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.SeriesId}`)}
onPress={() => router.push(`/series/${item.SeriesId}/page`)}
className="flex flex-col space-y-2 w-32"
>
<Poster

View File

@@ -1,105 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { Button } from "../Button";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useMemo } from "react";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
type?: "next" | "previous";
}
export const NextEpisodeButton: React.FC<Props> = ({
item,
type = "next",
...props
}) => {
const router = useRouter();
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],
queryFn: async () => {
if (
!api ||
!user?.Id ||
!item?.Id ||
!item?.ParentId ||
!item?.IndexNumber
)
return null;
const response = await getItemsApi(api).getItems({
parentId: item?.ParentId,
limit: 1,
startIndex: type === "next" ? item.IndexNumber : item.IndexNumber - 2,
});
return (response.data.Items?.[0] as BaseItemDto) || null;
},
enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
});
const disabled = useMemo(() => {
if (!nextEpisode) return true;
if (nextEpisode.Id === item.Id) return true;
return false;
}, [nextEpisode, type]);
if (item.Type !== "Episode") return null;
return (
<Button
onPress={() => router.replace(`/items/${nextEpisode?.Id}`)}
className={`h-12 aspect-square`}
disabled={disabled}
{...props}
>
{type === "next" ? (
<Ionicons name="chevron-forward" size={24} color="white" />
) : (
<Ionicons name="chevron-back" size={24} color="white" />
)}
</Button>
);
};

View File

@@ -1,15 +1,17 @@
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 "../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";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
@@ -24,7 +26,6 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
userId: user?.Id,
seriesId,
fields: ["MediaSourceCount"],
limit: 10,
})
).data.Items;
},
@@ -48,10 +49,10 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/items/${item.Id}`);
router.push(`/(auth)/items/${item.Id}/page`);
}}
key={item.Id}
className="flex flex-col w-44"
className="flex flex-col w-32"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />

View File

@@ -1,35 +1,26 @@
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 { atom, useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react";
import { router } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { DownloadItem } from "../DownloadItem";
import { Loader } from "../Loader";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
type Props = {
item: BaseItemDto;
};
type SeasonIndexState = {
[seriesId: string]: number;
};
export const seasonIndexAtom = atom<SeasonIndexState>({});
export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const seasonIndex = seasonIndexState[item.Id ?? ""];
const router = useRouter();
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const [selectedSeasonId, setSelectedSeasonId] = useState<string | null>(null);
const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id],
@@ -55,24 +46,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id,
});
useEffect(() => {
if (seasons && seasons.length > 0 && seasonIndex === undefined) {
const firstSeason = seasons[0];
if (firstSeason.IndexNumber !== undefined) {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: firstSeason.IndexNumber,
}));
}
}
}, [seasons, seasonIndex, setSeasonIndexState, item.Id]);
const selectedSeasonId: string | null = useMemo(
() =>
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
[seasons, seasonIndex]
);
const { data: episodes, isFetching } = useQuery({
const { data: episodes } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id) return [];
@@ -96,25 +70,22 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
});
// Used for height calculation
const [nrOfEpisodes, setNrOfEpisodes] = useState(0);
useEffect(() => {
if (episodes && episodes.length > 0) {
setNrOfEpisodes(episodes.length);
}
}, [episodes]);
if (!seasons || seasons.length === 0) return;
setSelectedSeasonId(
seasons.find((season: any) => season.IndexNumber === 1)?.Id
);
setSelectedSeason(1);
}, [seasons]);
return (
<View
style={{
minHeight: 144 * nrOfEpisodes,
}}
>
<View className="mb-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-row px-4">
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text>
<Text>Season {selectedSeason}</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
@@ -132,10 +103,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
<DropdownMenu.Item
key={season.Name}
onSelect={() => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
setSelectedSeason(season.IndexNumber);
setSelectedSeasonId(season.Id);
}}
>
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
@@ -143,8 +112,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/* Old View. Might have a setting later to manually select view. */}
{/* {episodes && (
{episodes && (
<View className="mt-4">
<HorizontalScroll<BaseItemDto>
data={episodes}
@@ -152,7 +120,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
<TouchableOpacity
key={item.Id}
onPress={() => {
router.push(`/(auth)/items/${item.Id}`);
router.push(`/(auth)/items/${item.Id}/page`);
}}
className="flex flex-col w-48"
>
@@ -162,56 +130,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
)}
/>
</View>
)} */}
<View className="px-4 flex flex-col my-4">
{isFetching ? (
<View
style={{
minHeight: 144 * nrOfEpisodes,
}}
className="flex flex-col items-center justify-center"
>
<Loader />
</View>
) : (
episodes?.map((e: BaseItemDto) => (
<TouchableOpacity
key={e.Id}
onPress={() => {
router.push(`/(auth)/items/${e.Id}`);
}}
className="flex flex-col mb-4"
>
<View className="flex flex-row items-center mb-2">
<View className="w-32 aspect-video overflow-hidden mr-2">
<ContinueWatchingPoster item={e} width={128} />
</View>
<View className="shrink">
<Text numberOfLines={2} className="">
{e.Name}
</Text>
<Text numberOfLines={1} className="text-xs text-neutral-500">
{`S${e.ParentIndexNumber?.toString()}:E${e.IndexNumber?.toString()}`}
</Text>
<Text className="text-xs text-neutral-500">
{runtimeTicksToSeconds(e.RunTimeTicks)}
</Text>
</View>
<View className="self-start ml-auto">
<DownloadItem item={e} />
</View>
</View>
<Text
numberOfLines={3}
className="text-xs text-neutral-500 shrink"
>
{e.Overview}
</Text>
</TouchableOpacity>
))
)}
</View>
)}
</View>
);
};

View File

@@ -1,37 +0,0 @@
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";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const SeriesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<>
<TouchableOpacity
onPress={() => router.push(`/(auth)/series/${item.SeriesId}`)}
>
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
</TouchableOpacity>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
</View>
<View>
<View className="flex flex-row items-center self-center">
<TouchableOpacity onPress={() => {}}>
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
</TouchableOpacity>
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
<Text className="text-center opacity-50">
{`Episode ${item.IndexNumber}`}
</Text>
</View>
</View>
</>
);
};

View File

@@ -1,358 +0,0 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { DownloadOptions, useSettings } from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { Linking, Switch, TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { Input } from "../common/Input";
import { useState } from "react";
import { Button } from "../Button";
export const SettingToggles: React.FC = () => {
const [settings, updateSettings] = useSettings();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState<string>("");
const queryClient = useQueryClient();
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
} = useQuery({
queryKey: ["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: 0,
});
return (
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Auto rotate</Text>
<Text className="text-xs opacity-50">
Important on android since the video player orientation is locked to
the app orientation.
</Text>
</View>
<Switch
value={settings?.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Download quality</Text>
<Text className="text-xs opacity-50">
Choose the search engine you want to use.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.downloadQuality?.label}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Quality</DropdownMenu.Label>
{DownloadOptions.map((option) => (
<DropdownMenu.Item
key={option.value}
onSelect={() => {
updateSettings({ downloadQuality: option });
}}
>
<DropdownMenu.ItemTitle>{option.label}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Start videos in fullscreen</Text>
<Text className="text-xs opacity-50">
Clicking a video will start it in fullscreen mode, instead of
inline.
</Text>
</View>
<Switch
value={settings?.openFullScreenVideoPlayerByDefault}
onValueChange={(value) =>
updateSettings({ openFullScreenVideoPlayerByDefault: value })
}
/>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Use external player (VLC)</Text>
<Text className="text-xs opacity-50 shrink">
Open all videos in VLC instead of the default player. This requries
VLC to be installed on the phone.
</Text>
</View>
<Switch
value={settings?.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
/>
</View>
<View className="flex flex-col">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col">
<Text className="font-semibold">Use popular lists plugin</Text>
<Text className="text-xs opacity-50">Made by: lostb1t</Text>
<TouchableOpacity
onPress={() => {
Linking.openURL(
"https://github.com/lostb1t/jellyfin-plugin-media-lists"
);
}}
>
<Text className="text-xs text-purple-600">More info</Text>
</TouchableOpacity>
</View>
<Switch
value={settings?.usePopularPlugin}
onValueChange={(value) =>
updateSettings({ usePopularPlugin: value })
}
/>
</View>
{settings?.usePopularPlugin && (
<View className="flex flex-col py-2 bg-neutral-900">
{mediaListCollections?.map((mlc) => (
<View
key={mlc.Id}
className="flex flex-row items-center justify-between bg-neutral-900 px-4 py-2"
>
<View className="flex flex-col">
<Text className="font-semibold">{mlc.Name}</Text>
</View>
<Switch
value={settings?.mediaListCollectionIds?.includes(mlc.Id!)}
onValueChange={(value) => {
if (!settings.mediaListCollectionIds) {
updateSettings({
mediaListCollectionIds: [mlc.Id!],
});
return;
}
updateSettings({
mediaListCollectionIds:
settings?.mediaListCollectionIds.includes(mlc.Id!)
? settings?.mediaListCollectionIds.filter(
(id) => id !== mlc.Id
)
: [...settings?.mediaListCollectionIds, mlc.Id!],
});
}}
/>
</View>
))}
{isLoadingMediaListCollections && (
<View className="flex flex-row items-center justify-center bg-neutral-900 p-4">
<Loader />
</View>
)}
{mediaListCollections?.length === 0 && (
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<Text className="text-xs opacity-50">
No collections found. Add some in Jellyfin.
</Text>
</View>
)}
</View>
)}
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
<View className="flex flex-col shrink">
<Text className="font-semibold">Force direct play</Text>
<Text className="text-xs opacity-50 shrink">
This will always request direct play. This is good if you want to
try to stream movies you think the device supports.
</Text>
</View>
<Switch
value={settings?.forceDirectPlay}
onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Device profile</Text>
<Text className="text-xs opacity-50">
A profile used for deciding what audio and video codecs the device
supports.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.deviceProfile}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ deviceProfile: "Expo" });
}}
>
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ deviceProfile: "Native" });
}}
>
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({ deviceProfile: "Old" });
}}
>
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-col">
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Search engine</Text>
<Text className="text-xs opacity-50">
Choose the search engine you want to use.
</Text>
</View>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>{settings?.searchEngine}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Jellyfin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ searchEngine: "Marlin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Marlin</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
{settings?.searchEngine === "Marlin" && (
<View className="flex flex-col bg-neutral-900 px-4 pb-4">
<>
<View className="flex flex-row items-center space-x-2">
<View className="grow">
<Input
placeholder="Marlin Server URL..."
defaultValue={settings.marlinServerUrl}
value={marlinUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setMarlinUrl(text)}
/>
</View>
<Button
color="purple"
className="shrink w-16 h-12"
onPress={() => {
updateSettings({ marlinServerUrl: marlinUrl });
}}
>
Save
</Button>
</View>
<Text className="text-neutral-500 mt-2">
{settings?.marlinServerUrl}
</Text>
</>
</View>
)}
</View>
</View>
);
};

View File

@@ -12,5 +12,5 @@ export const Colors = {
tint: tintColorDark,
icon: "#9BA1A6",
tabIconDefault: "#9BA1A6",
tabIconSelected: "#9333ea",
tabIconSelected: "#EE4B2B",
};

View File

@@ -20,19 +20,7 @@
"simulator": true
}
},
"production": {
"channel": "0.8.2",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.8.2",
"android": {
"buildType": "apk",
"image": "latest"
}
}
"production": {}
},
"submit": {
"production": {}

View File

@@ -26,12 +26,11 @@ export const useFiles = () => {
fileNames.map((item) =>
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
idempotent: true,
})
)
}),
),
);
await AsyncStorage.removeItem("downloaded_files");
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
queryClient.invalidateQueries({ queryKey: ["downloaded"] });
} catch (error) {
console.error("Failed to delete all files:", error);
}
@@ -50,7 +49,7 @@ export const useFiles = () => {
try {
await FileSystem.deleteAsync(
`${FileSystem.documentDirectory}/${id}.mp4`,
{ idempotent: true }
{ idempotent: true },
);
const currentFiles = await getDownloadedFiles();
@@ -58,7 +57,7 @@ export const useFiles = () => {
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles)
JSON.stringify(updatedFiles),
);
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });

View File

@@ -6,8 +6,6 @@ import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runningProcesses } from "@/utils/atoms/downloads";
import { writeToLog } from "@/utils/log";
import { useQueryClient } from "@tanstack/react-query";
import { Platform } from "react-native";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -16,9 +14,8 @@ import { Platform } from "react-native";
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
const [_, setProgress] = useAtom(runningProcesses);
const queryClient = useQueryClient();
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
@@ -26,104 +23,76 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
}
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
const command = `-y -fflags +genpts -i ${url} -c copy -bufsize 10M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(
async (url: string) => {
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(async () => {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
);
// let command: string | null = null;
try {
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
// if (Platform.OS === "android") {
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_mediacodec -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
// } else if (Platform.OS === "ios") {
// command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c:v h264_videotoolbox -c:a copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
// } else {
// throw new Error("Unsupported platform");
// }
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
);
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
try {
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
setProgress((prev) =>
prev?.item.Id === item.Id!
? { ...prev, progress: percentage, speed }
: prev
);
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
);
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
);
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`
);
resolve();
}
setProgress(null);
} catch (error) {
reject(error);
}
});
});
await queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
await queryClient.invalidateQueries({ queryKey: ["downloaded"] });
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`
setProgress((prev) =>
prev?.item.Id === item.Id!
? { ...prev, progress: percentage, speed }
: prev,
);
});
await FFmpegKit.executeAsync(command, async (session) => {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
);
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
}
setProgress(null);
throw error; // Re-throw the error to propagate it to the caller
}
},
[output, item, setProgress]
);
});
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
setProgress(null);
}
}, [output, item, command, setProgress]);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProgress(null);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
);
}, [item.Name, setProgress]);
@@ -138,7 +107,7 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
try {
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
(await AsyncStorage.getItem("downloaded_files")) || "[]",
);
const updatedFiles = [
...currentFiles.filter((i) => i.Id !== item.Id),
@@ -146,13 +115,13 @@ async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
];
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles)
JSON.stringify(updatedFiles),
);
} catch (error) {
console.error("Error updating downloaded files:", error);
writeToLog(
"ERROR",
`Failed to update downloaded files for item: ${item.Name}`
`Failed to update downloaded files for item: ${item.Name}`,
);
}
}

Some files were not shown because too many files have changed in this diff Show More