Compare commits

..

56 Commits

Author SHA1 Message Date
Fredrik Burmester
2e563bcbe0 chore 2024-08-21 23:21:59 +02:00
Fredrik Burmester
b1062628d9 fix: can't play local media bug
appeared after playback rewrite
2024-08-21 23:21:53 +02:00
Fredrik Burmester
e216c8392f fix: jellysearch + chromecast 2024-08-21 23:20:40 +02:00
Fredrik Burmester
1a47ade4dc chore: version 2024-08-21 21:05:53 +02:00
Fredrik Burmester
a54da1c3dc fix: open google search for actor if Marlin turned off 2024-08-21 20:37:50 +02:00
Fredrik Burmester
874364fcde feat: new list view for seasons with persistent season state 2024-08-21 20:34:52 +02:00
Fredrik Burmester
9059f33538 fix: smaller height for dropdown menus 2024-08-21 20:34:24 +02:00
Fredrik Burmester
27785e7d18 feat: new download quality options 2024-08-21 20:34:07 +02:00
Fredrik Burmester
9a621cab4e fix: add option for height 2024-08-21 20:21:21 +02:00
Fredrik Burmester
a37ac74e9f chore 2024-08-21 20:21:06 +02:00
Fredrik Burmester
4d4bb0f6a4 chore 2024-08-21 20:21:00 +02:00
Fredrik Burmester
6bd60b2ec6 fix: #78 2024-08-21 20:20:52 +02:00
Fredrik Burmester
696a2a4780 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-08-21 18:43:56 +02:00
Fredrik Burmester
13ac9b0443 chore 2024-08-21 18:43:54 +02:00
Fredrik Burmester
83407674be fix: slow down 2024-08-21 18:43:38 +02:00
Fredrik Burmester
53bb1751c2 fix: websocket should not try to connect without token 2024-08-21 18:43:27 +02:00
Fredrik Burmester
fa31ff8f2b Merge pull request #92 from lostb1t/master
Add DateCreated sort option an disable bit rate sort
2024-08-21 18:35:31 +02:00
lostb1t
f863c95f70 Disable bitrate filter 2024-08-21 18:00:06 +02:00
lostb1t
f227565dbf Use DateCreate for sorting last added 2024-08-21 17:58:42 +02:00
Fredrik Burmester
7abc629a10 fix: #87 2024-08-21 08:01:46 +02:00
Fredrik Burmester
70a250df5b fix: #86 and add docsting 2024-08-21 07:48:45 +02:00
Fredrik Burmester
dbdf56b990 Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-08-21 07:33:33 +02:00
Fredrik Burmester
8b2204896a chore 2024-08-21 07:33:22 +02:00
Fredrik Burmester
89729a95cd Merge pull request #85 from yihaolee85/master
Update [collectionId].tsx to fix sort order
2024-08-21 07:31:38 +02:00
yihaolee85
57dd3b8446 Update [collectionId].tsx to fix sort order
Update [collectionId].tsx to fix sort order
2024-08-21 10:02:15 +08:00
Fredrik Burmester
8d2a0378ca fix: correct sf collection use 2024-08-20 22:39:36 +02:00
Fredrik Burmester
cbe01a0012 feat: use external player (vlc) 2024-08-20 21:51:16 +02:00
Fredrik Burmester
eed4df6a8a fix: make posters a bit smaller 2024-08-20 19:18:51 +02:00
retardgerman
5e081751a4 updated discord invite link 2024-08-20 11:47:40 +02:00
Fredrik Burmester
09f953ebba fix: ws now works as expexted 2024-08-20 08:42:52 +02:00
Fredrik Burmester
4873aaf3df chore 2024-08-20 08:26:02 +02:00
Fredrik Burmester
9bbab4f46f chore 2024-08-20 08:25:05 +02:00
Fredrik Burmester
469e8b3f01 refactor: playing state 2024-08-20 08:24:05 +02:00
Fredrik Burmester
1c31458dd4 chore 2024-08-19 22:52:30 +02:00
Fredrik Burmester
4c097c557f fix: #79 2024-08-19 22:52:27 +02:00
Fredrik Burmester
c23ca905c8 fix: bug after refactor 2024-08-19 22:26:33 +02:00
Fredrik Burmester
ed3170af76 fix: invalid query 2024-08-19 22:05:23 +02:00
Fredrik Burmester
e22dd759c7 fix: #71 2024-08-19 22:05:17 +02:00
Fredrik Burmester
aa44caa161 chore 2024-08-19 21:55:40 +02:00
Fredrik Burmester
27260faea8 fix: #75 2024-08-19 21:55:32 +02:00
Fredrik Burmester
ec7e5f869d feat: first implementation of ws 2024-08-19 21:45:15 +02:00
Fredrik Burmester
8e1a07e819 chore 2024-08-19 21:44:00 +02:00
Fredrik Burmester
250c1968f3 chore 2024-08-19 21:43:56 +02:00
Fredrik Burmester
caeedfbc52 fix: separate item and show bar state 2024-08-19 21:43:48 +02:00
Fredrik Burmester
66ce6b2cfa fix: refactor to work like other inf scrolling pages 2024-08-19 21:43:14 +02:00
Fredrik Burmester
388480adef feat: use Marlin 2024-08-19 21:42:45 +02:00
Fredrik Burmester
e911f99b26 chore: rename to libraries 2024-08-19 21:42:16 +02:00
retardgerman
73ff0aa66a Moved reference to APKs to the download section 2024-08-19 17:10:10 +02:00
retardgerman
29ae6747c4 Merge pull request #68 from vicegold/master
fix marketplace logo alignment
2024-08-19 17:05:12 +02:00
Laurids Düllmann
44444e3b37 fix marketplace logo alignment 2024-08-19 17:02:27 +02:00
retardgerman
0e3f289d43 alternative apple App Store badge 2024-08-19 16:48:11 +02:00
retardgerman
a66648c67c Merge pull request #65 from FintasticMan/fix-season-numbers 2024-08-18 19:43:49 +02:00
retardgerman
6dc9538483 Merge pull request #62 from lostb1t/master
Update README.md with collection info
2024-08-18 18:04:54 +02:00
FintasticMan
cb7c018cf4 fix: season number for named seasons 2024-08-18 16:15:19 +02:00
lostb1t
a01217b8ac Update README.md with collection info 2024-08-18 15:40:40 +02:00
Fredrik Burmester
21c1221138 chore 2024-08-18 13:35:23 +02:00
64 changed files with 3308 additions and 9997 deletions

1
.gitignore vendored
View File

@@ -29,3 +29,4 @@ pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.ipa
.continuerc.json

View File

@@ -26,18 +26,46 @@ Streamyfin includes some exciting experimental features like media downloading a
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;">
<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=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
</a>
<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.
@@ -46,8 +74,6 @@ Get the latest updates by using the TestFlight version of the app.
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
Or download the APKs here on GitHub for Android.
## 🚀 Getting Started
### Prerequisites
@@ -102,7 +128,7 @@ Key points of the MPL-2.0:
## 🌐 Connect with Us
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/zyGKHJZvv4)
Join our Discord: [https://discord.gg/zyGKHJZvv4](https://discord.gg/aJvAYeycyY)
If you have questions or need support, feel free to reach out:

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.6.1",
"version": "0.8.1",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -30,7 +30,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 17,
"versionCode": 21,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},
@@ -46,9 +46,15 @@
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"@react-native-tvos/config-tv",
"expo-router",
"expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
[
"react-native-video",
{
@@ -61,6 +67,35 @@
"useExoplayerDash": false
}
}
],
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "14.0"
},
"android": {
"minSdkVersion": 24,
"usesCleartextTraffic": true,
"packagingOptions": {
"jniLibs": {
"useLegacyPackaging": true
}
}
}
}
],
[
"expo-screen-orientation",
{
"initialOrientation": "DEFAULT"
}
],
[
"expo-sensors",
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
]
],
"experiments": {

View File

@@ -1,10 +1,13 @@
import { router, Tabs } from "expo-router";
import React, { useEffect } from "react";
import * as NavigationBar from "expo-navigation-bar";
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
import { Platform, TouchableOpacity, View } from "react-native";
import { Feather } from "@expo/vector-icons";
import { Chromecast } from "@/components/Chromecast";
import { BlurView } from "expo-blur";
import * as NavigationBar from "expo-navigation-bar";
import { Tabs } from "expo-router";
import React, { useEffect } from "react";
import { Platform, StyleSheet } from "react-native";
import { StyleSheet } from "react-native";
export default function TabLayout() {
useEffect(() => {
@@ -70,7 +73,7 @@ export default function TabLayout() {
}}
/>
<Tabs.Screen
name="library"
name="libraries"
options={{
headerShown: false,
title: "Library",

View File

@@ -1,3 +1,4 @@
import { Chromecast } from "@/components/Chromecast";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, View } from "react-native";
@@ -16,8 +17,21 @@ export default function IndexLayout() {
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 File

@@ -5,6 +5,7 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
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 {
@@ -29,6 +30,7 @@ export default function index() {
const [user] = useAtom(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
@@ -169,21 +171,21 @@ export default function index() {
});
const { data: mediaListCollections } = useQuery({
queryKey: ["mediaListCollections-home", user?.Id],
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["medialist", "promoted"],
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return [];
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && false,
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
@@ -195,7 +197,10 @@ export default function index() {
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
await queryClient.refetchQueries({
queryKey: ["mediaListCollections-home"],
queryKey: ["sf_promoted"],
});
await queryClient.refetchQueries({
queryKey: ["sf_carousel"],
});
setLoading(false);
}, [queryClient, user?.Id]);
@@ -204,6 +209,21 @@ export default function index() {
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>
);
}
@@ -250,6 +270,10 @@ export default function index() {
orientation="horizontal"
/>
{mediaListCollections?.map((ml) => (
<MediaListSection key={ml.Id} collection={ml} />
))}
<ScrollingCollectionList
title="Recently Added in Movies"
data={recentlyAddedInMovies}

View File

@@ -19,12 +19,17 @@ import {
BaseItemDtoQueryResult,
BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getFilterApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import {
getFilterApi,
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { NativeScrollEvent, ScrollView, View } from "react-native";
import * as ScreenOrientation from "expo-screen-orientation";
const isCloseToBottom = ({
layoutMeasurement,
@@ -40,7 +45,7 @@ const isCloseToBottom = ({
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const { libraryId } = searchParams as { libraryId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -52,18 +57,39 @@ const page: React.FC = () => {
const [sortBy, setSortBy] = useAtom(sortByAtom);
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP
);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
// Set the initial orientation
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, [ScreenOrientation]);
const { data: library } = useQuery({
queryKey: ["library", libraryId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
const response = await getUserLibraryApi(api).getItem({
itemId: libraryId,
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
const data = response.data;
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
enabled: !!api && !!user?.Id && !!libraryId,
staleTime: 0,
});
@@ -73,11 +99,11 @@ const page: React.FC = () => {
}: {
pageParam: number;
}): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !collection) return null;
if (!api || !library) return null;
const includeItemTypes: BaseItemKind[] = [];
switch (collection?.CollectionType) {
switch (library?.CollectionType) {
case "movies":
includeItemTypes.push("Movie");
break;
@@ -96,7 +122,7 @@ const page: React.FC = () => {
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
parentId: libraryId,
limit: 66,
startIndex: pageParam,
sortBy: [sortBy[0].key, "SortName", "ProductionYear"],
@@ -116,8 +142,8 @@ const page: React.FC = () => {
[
api,
user?.Id,
collectionId,
collection?.CollectionType,
libraryId,
library,
selectedGenres,
selectedYears,
selectedTags,
@@ -129,7 +155,7 @@ const page: React.FC = () => {
const { data, isFetching, fetchNextPage } = useInfiniteQuery({
queryKey: [
"library-items",
collection,
library,
selectedGenres,
selectedYears,
selectedTags,
@@ -158,7 +184,7 @@ const page: React.FC = () => {
}
},
initialPageParam: 0,
enabled: !!api && !!user?.Id && !!collection,
enabled: !!api && !!user?.Id && !!library,
});
const type = useMemo(() => {
@@ -169,7 +195,7 @@ const page: React.FC = () => {
return data?.pages.flatMap((p) => p?.Items) || [];
}, [data]);
if (!collection || !collection.CollectionType) return null;
if (!library || !library.CollectionType) return null;
return (
<ScrollView
@@ -187,7 +213,7 @@ const page: React.FC = () => {
<View className="flex flex-row space-x-1 px-3">
<ResetFiltersButton />
<FilterButton
collectionId={collectionId}
collectionId={libraryId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
@@ -196,7 +222,7 @@ const page: React.FC = () => {
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
parentId: libraryId,
});
return response.data.Genres || [];
}}
@@ -209,7 +235,7 @@ const page: React.FC = () => {
}
/>
<FilterButton
collectionId={collectionId}
collectionId={libraryId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
@@ -218,7 +244,7 @@ const page: React.FC = () => {
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
parentId: libraryId,
});
return response.data.Tags || [];
}}
@@ -231,7 +257,7 @@ const page: React.FC = () => {
}
/>
<FilterButton
collectionId={collectionId}
collectionId={libraryId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
@@ -240,7 +266,7 @@ const page: React.FC = () => {
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
parentId: libraryId,
});
return (
response.data.Years?.sort((a, b) => b - a).map((y) =>
@@ -258,7 +284,7 @@ const page: React.FC = () => {
/>
<FilterButton
icon="sort"
collectionId={collectionId}
collectionId={libraryId}
queryKey="sortByFilter"
queryFn={async () => {
return sortOptions;
@@ -276,7 +302,7 @@ const page: React.FC = () => {
<FilterButton
icon="sort"
showSearch={false}
collectionId={collectionId}
collectionId={libraryId}
queryKey="orderByFilter"
queryFn={async () => {
return sortOrderOptions;
@@ -305,10 +331,16 @@ const page: React.FC = () => {
(item, index) =>
item && (
<TouchableItemRouter
key={`${item.Id}`}
key={`${item.Id}-${index}`}
style={{
width: "32%",
marginBottom: 4,
width:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? "32%"
: "20%",
marginBottom:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? 4
: 16,
}}
item={item}
className={`
@@ -322,7 +354,10 @@ const page: React.FC = () => {
{flatData.length % 3 !== 0 && (
<View
style={{
width: "33%",
width:
orientation === ScreenOrientation.Orientation.PORTRAIT_UP
? "32%"
: "20%",
}}
></View>
)}

View File

@@ -16,7 +16,7 @@ export default function IndexLayout() {
}}
/>
<Stack.Screen
name="collections/[collectionId]"
name="[libraryId]"
options={{
title: "",
headerShown: true,

View File

@@ -49,7 +49,7 @@ export default function index() {
paddingBottom: 150,
}}
data={data}
renderItem={({ item }) => <CollectionCard collection={item} />}
renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() => <View className="h-4" />}
estimatedItemSize={200}
@@ -58,10 +58,10 @@ export default function index() {
}
interface Props {
collection: BaseItemDto;
library: BaseItemDto;
}
const CollectionCard: React.FC<Props> = ({ collection }) => {
const LibraryItemCard: React.FC<Props> = ({ library }) => {
const router = useRouter();
const [api] = useAtom(apiAtom);
@@ -70,9 +70,9 @@ const CollectionCard: React.FC<Props> = ({ collection }) => {
() =>
getPrimaryImageUrl({
api,
item: collection,
item: library,
}),
[collection]
[library]
);
if (!url) return null;
@@ -80,7 +80,7 @@ const CollectionCard: React.FC<Props> = ({ collection }) => {
return (
<TouchableOpacity
onPress={() => {
router.push(`/library/collections/${collection.Id}`);
router.push(`/libraries/${library.Id}`);
}}
>
<View className="flex justify-center rounded-xl w-full relative border border-neutral-900 h-20 ">
@@ -96,7 +96,7 @@ const CollectionCard: React.FC<Props> = ({ collection }) => {
}}
/>
<Text className="font-bold text-xl text-start px-4">
{collection.Name}
{library.Name}
</Text>
</View>
</TouchableOpacity>

View File

@@ -1,3 +1,4 @@
import { Button } from "@/components/Button";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
@@ -9,13 +10,32 @@ import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getSearchApi } from "@jellyfin/sdk/lib/utils/api";
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 { router, useNavigation } from "expo-router";
import axios from "axios";
import {
Href,
router,
useLocalSearchParams,
useNavigation,
usePathname,
} from "expo-router";
import { useAtom } from "jotai";
import React, { useLayoutEffect, useMemo, useState } from "react";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useDebounce } from "use-debounce";
@@ -29,6 +49,10 @@ const exampleSearches = [
];
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);
@@ -36,107 +60,131 @@ export default function search() {
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) => setSearch(e.nativeEvent.text),
onChangeText: (e: any) => {
router.setParams({ q: "" });
setSearch(e.nativeEvent.text);
},
hideWhenScrolling: false,
autoFocus: true,
},
});
}, [navigation]);
const { data: movies, isLoading: l1 } = useQuery({
queryKey: ["search-movies", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Movie"],
});
return searchApi.data.SearchHints;
},
const { data: movies, isFetching: l1 } = useQuery({
queryKey: ["search", "movies", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Movie"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: series, isLoading: l2 } = useQuery({
queryKey: ["search-series", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Series"],
});
return searchApi.data.SearchHints;
},
const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Series"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: episodes, isLoading: l3 } = useQuery({
queryKey: ["search-episodes", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Episode"],
});
return searchApi.data.SearchHints;
},
const { data: episodes, isFetching: l3 } = useQuery({
queryKey: ["search", "episodes", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Episode"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: artists, isLoading: l4 } = useQuery({
queryKey: ["search-artists", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["MusicArtist"],
});
return searchApi.data.SearchHints;
},
const { data: artists, isFetching: l4 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicArtist"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: albums, isLoading: l5 } = useQuery({
queryKey: ["search-albums", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["MusicAlbum"],
});
return searchApi.data.SearchHints;
},
const { data: albums, isFetching: l5 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
}),
enabled: debouncedSearch.length > 0,
});
const { data: songs, isLoading: l6 } = useQuery({
queryKey: ["search-songs", debouncedSearch],
queryFn: async () => {
if (!api || !user || debouncedSearch.length === 0) return [];
const searchApi = await getSearchApi(api).getSearchHints({
searchTerm: debouncedSearch,
limit: 10,
includeItemTypes: ["Audio"],
});
return searchApi.data.SearchHints;
},
const { data: songs, isFetching: l6 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Audio"],
}),
enabled: debouncedSearch.length > 0,
});
const noResults = useMemo(() => {
@@ -173,6 +221,13 @@ export default function search() {
/>
</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!)}
@@ -182,7 +237,7 @@ export default function search() {
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-32"
className="flex flex-col w-28"
onPress={() => router.push(`/items/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
@@ -207,7 +262,7 @@ export default function search() {
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}`)}
className="flex flex-col w-32"
className="flex flex-col w-28"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
@@ -231,7 +286,7 @@ export default function search() {
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/${item.Id}`)}
className="flex flex-col w-48"
className="flex flex-col w-44"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
@@ -250,7 +305,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-32"
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
@@ -269,7 +324,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-32"
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
@@ -288,7 +343,7 @@ export default function search() {
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-32"
className="flex flex-col w-28"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />

View File

@@ -1,3 +1,4 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { SongsList } from "@/components/music/SongsList";
import ArtistPoster from "@/components/posters/ArtistPoster";
@@ -23,6 +24,16 @@ export default function page() {
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const { data: album } = useQuery({
queryKey: ["album", albumId, artistId],
queryFn: async () => {

View File

@@ -1,20 +1,46 @@
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 ArtistPoster from "@/components/posters/ArtistPoster";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import {
BaseItemDto,
genreFilterAtom,
sortByAtom,
sortOptions,
sortOrderAtom,
sortOrderOptions,
tagsFilterAtom,
yearFilterAtom,
} from "@/utils/atoms/filters";
import {
BaseItemDtoQueryResult,
BaseItemKind,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import {
getFilterApi,
getItemsApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import React, { useCallback, useEffect, useMemo } from "react";
import { NativeScrollEvent, ScrollView, View } from "react-native";
const isCloseToBottom = ({
layoutMeasurement,
contentOffset,
contentSize,
}: NativeScrollEvent) => {
const paddingToBottom = 200;
return (
layoutMeasurement.height + contentOffset.y >=
contentSize.height - paddingToBottom
);
};
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
@@ -22,200 +48,294 @@ const page: React.FC = () => {
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);
useEffect(() => {
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 getItemsApi(api).getItems({
const response = await getUserLibraryApi(api).getItem({
itemId: collectionId,
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
const data = response.data;
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
const [startIndex, setStartIndex] = useState<number>(0);
useEffect(() => {
navigation.setOptions({ title: collection?.Name || "" });
}, [navigation, collection]);
const { data, isLoading, isError } = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collection?.Id, startIndex],
queryFn: async () => {
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const sortBy: ItemSortBy[] = [];
const includeItemTypes: BaseItemKind[] = [];
switch (collection?.CollectionType) {
case "movies":
sortBy.push("SortName", "ProductionYear");
break;
case "boxsets":
sortBy.push("IsFolder", "SortName");
break;
default:
sortBy.push("SortName");
break;
}
switch (collection?.CollectionType) {
case "movies":
includeItemTypes.push("Movie");
break;
case "boxsets":
includeItemTypes.push("BoxSet");
break;
case "tvshows":
includeItemTypes.push("Series");
break;
case "music":
includeItemTypes.push("MusicAlbum");
break;
default:
break;
}
const 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: 100,
startIndex,
sortBy,
sortOrder: ["Ascending"],
includeItemTypes,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
recursive: true,
imageTypeLimit: 1,
fields: ["PrimaryImageAspectRatio", "SortName"],
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)),
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
return response.data || null;
},
enabled: !!collection?.Id && !!api && !!user?.Id,
[
api,
user?.Id,
collection,
selectedGenres,
selectedYears,
selectedTags,
sortBy,
sortOrder,
]
);
const { data, isFetching, fetchNextPage } = 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 totalItems = useMemo(() => {
return data?.TotalRecordCount;
useEffect(() => {
console.log("Data: ", data);
}, [data]);
const type = useMemo(() => {
return data?.pages.flatMap((page) => page?.Items)[0]?.Type || null;
}, [data]);
const flatData = useMemo(() => {
return data?.pages.flatMap((p) => p?.Items) || [];
}, [data]);
if (!collection) return null;
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}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
{totalItems}
</Text>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => Math.max(prev - 100, 0));
<ScrollView
contentInsetAdjustmentBehavior="automatic"
onScroll={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent)) {
fetchNextPage();
}
}}
scrollEventThrottle={400}
>
<View className="mt-4 mb-24">
<View className="mb-4">
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="flex flex-row space-x-1 px-3">
<ResetFiltersButton />
<FilterButton
collectionId={collectionId}
queryKey="genreFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
});
return response.data.Genres || [];
}}
>
<Ionicons
name="arrow-back-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setStartIndex((prev) => prev + 100);
set={setSelectedGenres}
values={selectedGenres}
title="Genres"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
collectionId={collectionId}
queryKey="tagsFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
});
return response.data.Tags || [];
}}
>
<Ionicons
name="arrow-forward-circle-outline"
size={32}
color="white"
/>
</TouchableOpacity>
set={setSelectedTags}
values={selectedTags}
title="Tags"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
collectionId={collectionId}
queryKey="yearFilter"
queryFn={async () => {
if (!api) return null;
const response = await getFilterApi(
api
).getQueryFiltersLegacy({
userId: user?.Id,
includeItemTypes: type ? [type] : [],
parentId: collectionId,
});
return (
response.data.Years?.sort((a, b) => b - a).map((y) =>
y.toString()
) || []
);
}}
set={setSelectedYears}
values={selectedYears}
title="Years"
renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase())
}
/>
<FilterButton
icon="sort"
collectionId={collectionId}
queryKey="sortByFilter"
queryFn={async () => {
return sortOptions;
}}
set={setSortBy}
values={sortBy}
title="Sort by"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase()) ||
item.value.toLowerCase().includes(search.toLowerCase())
}
showSearch={false}
/>
<FilterButton
icon="sort"
showSearch={false}
collectionId={collectionId}
queryKey="orderByFilter"
queryFn={async () => {
return sortOrderOptions;
}}
set={setSortOrder}
values={sortOrder}
title="Order by"
renderItemLabel={(item) => item.value}
searchFilter={(item, search) =>
item.value.toLowerCase().includes(search.toLowerCase()) ||
item.value.toLowerCase().includes(search.toLowerCase())
}
/>
</View>
</View>
</ScrollView>
{!type && isFetching && (
<Loader
style={{
marginTop: 300,
}}
/>
)}
</View>
<View className="flex flex-row flex-wrap px-4 justify-between after:content-['']">
{flatData.map(
(item, index) =>
item && (
<TouchableItemRouter
key={`${item.Id}`}
style={{
width: "32%",
marginBottom: 4,
}}
item={item}
className={`
`}
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)
)}
{flatData.length % 3 !== 0 && (
<View
style={{
width: "33%",
}}
></View>
)}
</View>
{isLoading ? (
<View className="my-12">
<Loader />
</View>
) : (
<View className="flex flex-row flex-wrap">
{data?.Items?.map((item: BaseItemDto, index: number) => (
<TouchableOpacity
style={{
maxWidth: "33%",
width: "100%",
padding: 10,
}}
key={index}
onPress={() => {
if (item?.Type === "Series") {
router.push(`/series/${item.Id}`);
} else if (item.IsFolder) {
router.push(`/collections/${item?.Id}`);
} else {
router.push(`/items/${item.Id}`);
}
}}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" ? (
<MoviePoster item={item} />
) : collection?.CollectionType === "music" ? (
<ArtistPoster item={item} />
) : (
<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>
);
};

179
app/(auth)/downloads.tsx Normal file
View File

@@ -0,0 +1,179 @@
import { Text } from "@/components/common/Text";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { Loader } from "@/components/Loader";
import { runningProcesses } from "@/utils/atoms/downloads";
import { queueAtom } from "@/utils/atoms/queue";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
const downloads: React.FC = () => {
const [process, setProcess] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { data: downloadedFiles, isLoading } = useQuery({
queryKey: ["downloaded_files", process?.item.Id],
queryFn: async () =>
JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
) as BaseItemDto[],
staleTime: 0,
});
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
[downloadedFiles]
);
const groupedBySeries = useMemo(() => {
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
const series: { [key: string]: BaseItemDto[] } = {};
episodes?.forEach((e) => {
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
series[e.SeriesName!].push(e);
});
return Object.values(series);
}, [downloadedFiles]);
const eta = useMemo(() => {
const length = process?.item?.RunTimeTicks || 0;
if (!process?.speed || !process?.progress) return "";
const timeLeft =
(length - length * (process.progress / 100)) / process.speed;
return formatNumber(timeLeft / 10000);
}, [process]);
if (isLoading) {
return (
<View className="h-full flex flex-col items-center justify-center -mt-6">
<Loader />
</View>
);
}
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}
</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>
</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>
</TouchableOpacity>
) : (
<Text className="opacity-50">No active downloads</Text>
)}
</View>
</View>
{movies.length > 0 && (
<View className="mb-4">
<View className="flex flex-row items-center justify-between mb-2">
<Text className="text-2xl font-bold">Movies</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{movies?.length}</Text>
</View>
</View>
{movies?.map((item: BaseItemDto) => (
<View className="mb-2 last:mb-0" key={item.Id}>
<MovieCard item={item} />
</View>
))}
</View>
)}
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
<SeriesCard items={items} key={items[0].SeriesId} />
))}
</View>
</ScrollView>
);
};
export default downloads;
/*
* Format a number (Date.getTime) to a human readable string ex. 2m 34s
* @param {number} num - The number to format
*
* @returns {string} - The formatted string
*/
const formatNumber = (num: number) => {
const minutes = Math.floor(num / 60000);
const seconds = ((num % 60000) / 1000).toFixed(0);
return `${minutes}m ${seconds}s`;
};

View File

@@ -1,10 +1,6 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import {
currentlyPlayingItemAtom,
fullScreenAtom,
playingAtom,
} from "@/components/CurrentlyPlayingBar";
import { DownloadItem } from "@/components/DownloadItem";
import { Loader } from "@/components/Loader";
import { OverviewText } from "@/components/OverviewText";
import { PlayButton } from "@/components/PlayButton";
@@ -24,6 +20,7 @@ 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";
@@ -32,8 +29,9 @@ 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 { 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 = () => {
@@ -45,9 +43,7 @@ const page: React.FC = () => {
const [settings] = useSettings();
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const [, setFullscreen] = useAtom(fullScreenAtom);
const castDevice = useCastDevice();
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
@@ -89,6 +85,7 @@ const page: React.FC = () => {
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
selectedSubtitleStream,
settings,
@@ -98,7 +95,9 @@ const page: React.FC = () => {
let deviceProfile: any = ios;
if (settings?.deviceProfile === "Native") {
if (castDevice?.deviceId) {
deviceProfile = chromecastProfile;
} else if (settings?.deviceProfile === "Native") {
deviceProfile = native;
} else if (settings?.deviceProfile === "Old") {
deviceProfile = old;
@@ -125,22 +124,6 @@ const page: React.FC = () => {
staleTime: 0,
});
const onPressPlay = useCallback(
async (type: "device" | "cast" = "device") => {
if (!playbackUrl || !item) return;
setCurrentlyPlying({
item,
playbackUrl,
});
setPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault === true) {
setFullscreen(true);
}
},
[playbackUrl, item, settings]
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
@@ -210,6 +193,11 @@ const page: React.FC = () => {
</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>
@@ -234,12 +222,7 @@ const page: React.FC = () => {
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={false}
onPress={onPressPlay}
className="grow"
/>
<PlayButton item={item} url={playbackUrl} className="grow" />
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>

View File

@@ -43,7 +43,7 @@ const page: React.FC = () => {
quality: 90,
width: 1000,
}),
[item],
[item]
);
const logoUrl = useMemo(
@@ -52,7 +52,7 @@ const page: React.FC = () => {
api,
item,
}),
[item],
[item]
);
if (!item || !backdropUrl) return null;
@@ -87,7 +87,7 @@ const page: React.FC = () => {
</>
}
>
<View className="flex flex-col pt-4 pb-24">
<View className="flex flex-col pt-4">
<View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text>

View File

@@ -6,9 +6,14 @@ import { clearLogs, readFromLog } from "@/utils/log";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { ScrollView, View } from "react-native";
import * as Haptics from "expo-haptics";
import { useFiles } from "@/hooks/useFiles";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { WebSocketsTest } from "@/components/settings/WebsocketsText";
export default function settings() {
const { logout } = useJellyfin();
const { deleteAllFiles } = useFiles();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -29,14 +34,30 @@ export default function settings() {
<ListItem title="Server" subTitle={api?.basePath} />
</View>
<SettingToggles />
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
</Button>
<Button
color="red"
onPress={async () => {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
>
Delete all logs

View File

@@ -1,10 +1,8 @@
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import {
currentlyPlayingItemAtom,
playingAtom,
} from "@/components/CurrentlyPlayingBar";
import { DownloadItem } from "@/components/DownloadItem";
import { Loader } from "@/components/Loader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { ParallaxScrollView } from "@/components/ParallaxPage";
@@ -13,18 +11,25 @@ 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, useMemo, useState } from "react";
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();
@@ -33,10 +38,22 @@ const page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [, setPlaying] = useAtom(playingAtom);
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);
@@ -93,6 +110,7 @@ const page: React.FC = () => {
"playbackUrl",
item?.Id,
maxBitrate,
castDevice,
selectedAudioStream,
selectedSubtitleStream,
],
@@ -106,7 +124,7 @@ const page: React.FC = () => {
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
maxStreamingBitrate: maxBitrate.value,
sessionData,
deviceProfile: ios,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
});
@@ -119,17 +137,37 @@ const page: React.FC = () => {
staleTime: 0,
});
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const client = useRemoteMediaClient();
const onPressPlay = useCallback(
async (type: "device" | "cast" = "device") => {
if (!playbackUrl || !item) return;
setCp({
item,
playbackUrl,
});
setPlaying(true);
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]
);
@@ -178,6 +216,14 @@ const page: React.FC = () => {
<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">
@@ -198,12 +244,7 @@ const page: React.FC = () => {
</View>
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={false}
onPress={onPressPlay}
className="grow"
/>
<PlayButton item={item} className="grow" />
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>

View File

@@ -1,5 +1,7 @@
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";
@@ -8,6 +10,7 @@ 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 * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { Provider as JotaiProvider } from "jotai";
@@ -45,6 +48,8 @@ export default function RootLayout() {
}
function Layout() {
const [settings, updateSettings] = useSettings();
useKeepAwake();
const queryClientRef = useRef<QueryClient>(
@@ -61,99 +66,121 @@ function Layout() {
})
);
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}>
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<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)/items/[id]"
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>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>
<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)/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>
);

View File

@@ -44,8 +44,11 @@ const Login: React.FC = () => {
await login(credentials.username, credentials.password);
}
} catch (error) {
const e = error as AxiosError;
setError(e.message);
if (error instanceof Error) {
setError(error.message);
} else {
setError("An unexpected error occurred");
}
} finally {
setLoading(false);
}

View File

@@ -0,0 +1,40 @@
<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>

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -1,4 +1,5 @@
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";
@@ -35,9 +36,45 @@ export const AudioTrackSelector: React.FC<Props> = ({
}, []);
return (
<View
className="flex flex-row items-center justify-between"
{...props}
></View>
<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,4 +1,5 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
@@ -45,9 +46,42 @@ export const BitrateSelector: React.FC<Props> = ({
...props
}) => {
return (
<View
className="flex flex-row items-center justify-between"
{...props}
></View>
<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,3 +1,4 @@
import * as Haptics from "expo-haptics";
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader";
@@ -50,6 +51,7 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress={() => {
if (!loading && !disabled && onPress) {
onPress();
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
}}
disabled={disabled || loading}

View File

@@ -9,10 +9,12 @@ 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);
@@ -21,23 +23,33 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
getPrimaryImageUrl({
api,
item,
quality: 70,
width: 300,
quality: 90,
width: 176 * 2,
}),
[item],
[item]
);
const [progress, setProgress] = useState(
item.UserData?.PlayedPercentage || 0,
item.UserData?.PlayedPercentage || 0
);
if (!url)
return (
<View className="w-48 aspect-video border border-neutral-800"></View>
<View
className="aspect-video border border-neutral-800"
style={{
width,
}}
></View>
);
return (
<View className="w-48 relative aspect-video rounded-lg overflow-hidden border border-neutral-800">
<View
style={{
width,
}}
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
>
<Image
key={item.Id}
id={item.Id}

View File

@@ -1,50 +1,39 @@
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 { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import Video from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
playbackUrl: string;
} | null>(null);
export const playingAtom = atom(false);
export const fullScreenAtom = atom(false);
export const CurrentlyPlayingBar: React.FC = () => {
const queryClient = useQueryClient();
const segments = useSegments();
const {
currentlyPlaying,
pauseVideo,
playVideo,
setCurrentlyPlayingState,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
} = usePlayback();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [playing, setPlaying] = useAtom(playingAtom);
const [currentlyPlaying, setCurrentlyPlaying] = useAtom(
currentlyPlayingItemAtom
);
const [fullScreen, setFullScreen] = useAtom(fullScreenAtom);
const videoRef = useRef<VideoRef | null>(null);
const [progress, setProgress] = useState(0);
const aBottom = useSharedValue(0);
const aPadding = useSharedValue(0);
@@ -92,108 +81,28 @@ export const CurrentlyPlayingBar: React.FC = () => {
}
}, [segments]);
const { data: item } = useQuery({
queryKey: ["item", currentlyPlaying?.item.Id],
queryFn: async () =>
await getUserItemData({
api,
userId: user?.Id,
itemId: currentlyPlaying?.item.Id,
}),
enabled: !!currentlyPlaying?.item.Id && !!api,
staleTime: 60,
});
const { data: sessionData } = useQuery({
queryKey: ["sessionData", currentlyPlaying?.item.Id],
queryFn: async () => {
if (!currentlyPlaying?.item.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: currentlyPlaying?.item.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
staleTime: 0,
});
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
if (
!currentTime ||
!sessionData?.PlaySessionId ||
!playing ||
!api ||
!currentlyPlaying?.item.Id
)
return;
const newProgress = currentTime * 10000000;
setProgress(newProgress);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: newProgress,
sessionId: sessionData.PlaySessionId,
});
},
[sessionData?.PlaySessionId, api, playing, currentlyPlaying?.item.Id]
);
useEffect(() => {
if (!item || !api) return;
if (playing) {
videoRef.current?.resume();
} else {
videoRef.current?.pause();
if (progress > 0 && sessionData?.PlaySessionId)
reportPlaybackStopped({
api,
itemId: item?.Id,
positionTicks: progress,
sessionId: sessionData?.PlaySessionId,
});
queryClient.invalidateQueries({
queryKey: ["nextUp", item?.SeriesId],
refetchType: "all",
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
refetchType: "all",
});
}
}, [playing, progress, item, sessionData]);
useEffect(() => {
if (fullScreen === true) {
videoRef.current?.presentFullscreenPlayer();
} else {
videoRef.current?.dismissFullscreenPlayer();
}
}, [fullScreen]);
const startPosition = useMemo(
() =>
item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
? Math.round(
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
)
: 0,
[item]
[currentlyPlaying?.item]
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
item: currentlyPlaying?.item,
quality: 70,
width: 200,
}),
[item]
[currentlyPlaying?.item, api]
);
if (!currentlyPlaying || !api) return null;
if (!api || !currentlyPlaying) return null;
return (
<Animated.View
@@ -220,10 +129,14 @@ export const CurrentlyPlayingBar: React.FC = () => {
videoRef.current?.presentFullscreenPlayer();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
${
currentlyPlaying.item?.Type === "Audio"
? "aspect-square"
: "aspect-video"
}
`}
>
{currentlyPlaying.playbackUrl && (
{currentlyPlaying?.url && (
<Video
ref={videoRef}
allowsExternalPlayback
@@ -235,7 +148,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
controls={false}
pictureInPicture={true}
poster={
backdropUrl && item?.Type === "Audio"
backdropUrl && currentlyPlaying.item?.Type === "Audio"
? backdropUrl
: undefined
}
@@ -243,13 +156,13 @@ export const CurrentlyPlayingBar: React.FC = () => {
enable: true,
thread: true,
}}
paused={!playing}
paused={!isPlaying}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
source={{
uri: currentlyPlaying.playbackUrl,
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
@@ -257,22 +170,18 @@ export const CurrentlyPlayingBar: React.FC = () => {
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onFullscreenPlayerDidDismiss={() => {
setFullScreen(false);
}}
onFullscreenPlayerDidPresent={() => {
setFullScreen(true);
}}
onFullscreenPlayerDidDismiss={() => {}}
onFullscreenPlayerDidPresent={() => {}}
onPlaybackStateChanged={(e) => {
if (e.isPlaying) {
setPlaying(true);
setIsPlaying(true);
} else if (e.isSeeking) {
return;
} else {
setPlaying(false);
setIsPlaying(false);
}
}}
progressUpdateInterval={1000}
progressUpdateInterval={2000}
onError={(e) => {
console.log(e);
writeToLog(
@@ -280,11 +189,11 @@ export const CurrentlyPlayingBar: React.FC = () => {
"Video playback error: " + JSON.stringify(e)
);
Alert.alert("Error", "Cannot play this video file.");
setPlaying(false);
setCurrentlyPlaying(null);
setIsPlaying(false);
// setCurrentlyPlaying(null);
}}
renderLoader={
item?.Type !== "Audio" && (
currentlyPlaying.item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<Loader />
</View>
@@ -296,37 +205,41 @@ export const CurrentlyPlayingBar: React.FC = () => {
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
if (item?.Type === "Audio")
router.push(`/albums/${item?.AlbumId}`);
else router.push(`/items/${item?.Id}`);
if (currentlyPlaying.item?.Type === "Audio")
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
else router.push(`/items/${currentlyPlaying.item?.Id}`);
}}
>
<Text>{item?.Name}</Text>
<Text>{currentlyPlaying.item?.Name}</Text>
</TouchableOpacity>
{item?.Type === "Episode" && (
{currentlyPlaying.item?.Type === "Episode" && (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/series/${item.SeriesId}`);
router.push(
`/(auth)/series/${currentlyPlaying.item.SeriesId}`
);
}}
className="text-xs opacity-50"
>
<Text>{item.SeriesName}</Text>
<Text>{currentlyPlaying.item.SeriesName}</Text>
</TouchableOpacity>
)}
{item?.Type === "Movie" && (
{currentlyPlaying.item?.Type === "Movie" && (
<View>
<Text className="text-xs opacity-50">
{item?.ProductionYear}
{currentlyPlaying.item?.ProductionYear}
</Text>
</View>
)}
{item?.Type === "Audio" && (
{currentlyPlaying.item?.Type === "Audio" && (
<TouchableOpacity
onPress={() => {
router.push(`/albums/${item?.AlbumId}`);
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
}}
>
<Text className="text-xs opacity-50">{item?.Album}</Text>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
</TouchableOpacity>
)}
</View>
@@ -334,12 +247,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
if (playing) setPlaying(false);
else setPlaying(true);
if (isPlaying) pauseVideo();
else playVideo();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
{playing ? (
{isPlaying ? (
<Ionicons name="pause" size={24} color="white" />
) : (
<Ionicons name="play" size={24} color="white" />
@@ -347,7 +260,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setCurrentlyPlaying(null);
stopPlayback();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>

233
components/DownloadItem.tsx Normal file
View File

@@ -0,0 +1,233 @@
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 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 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";
interface DownloadProps extends TouchableOpacityProps {
item: BaseItemDto;
}
export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings();
const { startRemuxing } = useRemuxHlsToMp4(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,
});
if (isFetching) {
return (
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Loader />
</View>
);
}
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">
{process.progress === 0 ? (
<Loader />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</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">
<Ionicons name="cloud-download-outline" size={26} color="white" />
</View>
</TouchableOpacity>
);
}
};

View File

@@ -8,17 +8,6 @@ type ItemCardProps = {
item: BaseItemDto;
};
function seasonNameToIndex(seasonName: string | null | undefined) {
if (!seasonName) return -1;
if (seasonName.startsWith("Season")) {
return parseInt(seasonName.replace("Season ", ""));
}
if (seasonName.startsWith("Specials")) {
return 0;
}
return -1;
}
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className="mt-2 flex flex-col h-12">
@@ -28,9 +17,7 @@ export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
{item.SeriesName}
</Text>
<Text numberOfLines={1} className="text-xs opacity-50">
{`S${seasonNameToIndex(
item?.SeasonName,
)}:E${item.IndexNumber?.toString()}`}{" "}
{`S${item.ParentIndexNumber?.toString()}:E${item.IndexNumber?.toString()}`}{" "}
{item.Name}
</Text>
</>

View File

@@ -9,6 +9,7 @@ import Animated, {
useScrollViewOffset,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
const HEADER_HEIGHT = 400;
@@ -32,14 +33,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],
),
},
],
@@ -72,6 +73,15 @@ 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}

View File

@@ -1,27 +1,30 @@
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;
onPress: (type?: "cast" | "device") => void;
chromecastReady: boolean;
item?: BaseItemDto | null;
url?: string | null;
}
export const PlayButton: React.FC<Props> = ({
item,
onPress,
chromecastReady,
...props
}) => {
export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const { showActionSheetWithOptions } = useActionSheet();
const client = useRemoteMediaClient();
const { setCurrentlyPlayingState } = usePlayback();
const _onPress = () => {
if (!chromecastReady) {
onPress("device");
const onPress = async () => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
return;
}
@@ -33,28 +36,45 @@ export const PlayButton: React.FC<Props> = ({
options,
cancelButtonIndex,
},
(selectedIndex: number | undefined) => {
async (selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
onPress("cast");
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:
onPress("device");
setCurrentlyPlayingState({ item, url });
break;
case cancelButtonIndex:
break;
}
},
}
);
};
return (
<Button
onPress={_onPress}
onPress={onPress}
iconRight={
<View className="flex flex-row items-center space-x-2">
<Ionicons name="play-circle" size={24} color="white" />
{chromecastReady && <Feather name="cast" size={22} color="white" />}
{client && <Feather name="cast" size={22} color="white" />}
</View>
}
{...props}

View File

@@ -4,6 +4,7 @@ import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
import { Ionicons } from "@expo/vector-icons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import React from "react";
import { TouchableOpacity, View } from "react-native";
@@ -40,6 +41,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
{item.UserData?.Played ? (
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsNotPlayed({
api: api,
itemId: item?.Id,
@@ -55,6 +57,7 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
) : (
<TouchableOpacity
onPress={async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
await markAsPlayed({
api: api,
item: item,

View File

@@ -1,4 +1,5 @@
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";
@@ -43,9 +44,55 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
if (subtitleStreams.length === 0) return null;
return (
<View
className="flex flex-row items-center justify-between"
{...props}
></View>
<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>
);
};

View File

@@ -8,6 +8,7 @@ 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;
@@ -22,6 +23,8 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
return (
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (item.Type === "Series") {
router.push(`/series/${item.Id}`);
return;
@@ -43,7 +46,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
return;
}
// Movies and all other cases
if (item.Type === "BoxSet") {
router.push(`/collections/${item.Id}`);
return;

View File

@@ -0,0 +1,84 @@
import React, { useCallback } from "react";
import { TouchableOpacity } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as ContextMenu from "zeego/context-menu";
import * as Haptics from "expo-haptics";
import * as FileSystem from "expo-file-system";
import { useAtom } from "jotai";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
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 }) => {
const { deleteFile } = useFiles();
const { setCurrentlyPlayingState } = usePlayback();
const handleOpenFile = useCallback(async () => {
setCurrentlyPlayingState({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, setCurrentlyPlayingState]);
/**
* 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 = [
{
label: "Delete",
onSelect: handleDeleteFile,
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.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
);
};

View File

@@ -0,0 +1,92 @@
import React, { useCallback } from "react";
import { TouchableOpacity, View } from "react-native";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import * as ContextMenu from "zeego/context-menu";
import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import { 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 }) => {
const { deleteFile } = useFiles();
const [settings] = useSettings();
const { setCurrentlyPlayingState } = usePlayback();
const handleOpenFile = useCallback(() => {
setCurrentlyPlayingState({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, setCurrentlyPlayingState]);
/**
* 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 = [
{
label: "Delete",
onSelect: handleDeleteFile,
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.ItemTitle style={{ color: "red" }}>
{option.label}
</ContextMenu.ItemTitle>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Root>
);
};

View File

@@ -0,0 +1,49 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View } from "react-native";
import { EpisodeCard } from "./EpisodeCard";
import { Text } from "../common/Text";
import { useMemo } from "react";
import { SeasonPicker } from "../series/SeasonPicker";
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
const groupBySeason = useMemo(() => {
const seasons: Record<string, BaseItemDto[]> = {};
items.forEach((item) => {
if (!seasons[item.SeasonName!]) {
seasons[item.SeasonName!] = [];
}
seasons[item.SeasonName!].push(item);
});
return Object.values(seasons).sort(
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
);
}, [items]);
return (
<View>
<View className="flex flex-row items-center justify-between">
<Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
<Text className="text-xs font-bold">{items.length}</Text>
</View>
</View>
<Text className="opacity-50 mb-2">TV-Series</Text>
{groupBySeason.map((seasonItems, seasonIndex) => (
<View key={seasonIndex}>
<Text className="mb-2 font-semibold">
{seasonItems[0].SeasonName}
</Text>
{seasonItems.map((item, index) => (
<View className="mb-2" key={index}>
<EpisodeCard item={item} />
</View>
))}
</View>
))}
</View>
);
};

View File

@@ -1,8 +1,6 @@
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { FontAwesome, Ionicons } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useState } from "react";
import { TouchableOpacity, View, ViewProps } from "react-native";
import { FilterSheet } from "./FilterSheet";
@@ -59,7 +57,7 @@ export const FilterButton = <T,>({
>
<Text
className={`
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
${values.length > 0 ? "text-purple-100" : "text-neutral-100"}
text-xs font-semibold`}
>
{title}

View File

@@ -34,6 +34,34 @@ interface Props<T> extends ViewProps {
const LIMIT = 100;
/**
* FilterSheet Component
*
* This component creates a bottom sheet modal for filtering and selecting items from a list.
*
* @template T - The type of items in the list
*
* @param {Object} props - The component props
* @param {boolean} props.open - Whether the bottom sheet is open
* @param {function} props.setOpen - Function to set the open state
* @param {T[] | null} [props.data] - The full list of items to filter from
* @param {T[]} props.values - The currently selected items
* @param {function} props.set - Function to update the selected items
* @param {string} props.title - The title of the bottom sheet
* @param {function} props.searchFilter - Function to filter items based on search query
* @param {function} props.renderItemLabel - Function to render the label for each item
* @param {boolean} [props.showSearch=true] - Whether to show the search input
*
* @returns {React.ReactElement} The FilterSheet component
*
* Features:
* - Displays a list of items in a bottom sheet
* - Allows searching and filtering of items
* - Supports single selection of items
* - Loads items in batches for performance optimization
* - Customizable item rendering
*/
export const FilterSheet = <T,>({
values,
data: _data,
@@ -65,6 +93,8 @@ export const FilterSheet = <T,>({
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);
@@ -146,16 +176,14 @@ export const FilterSheet = <T,>({
<>
<TouchableOpacity
onPress={() => {
set(
values.includes(item)
? values.filter((i) => i !== item)
: [item]
);
setTimeout(() => {
setOpen(false);
}, 250);
if (!values.includes(item)) {
set([item]);
setTimeout(() => {
setOpen(false);
}, 250);
}
}}
key={index}
key={`${index}`}
className=" bg-neutral-800 px-4 py-3 flex flex-row items-center justify-between"
>
<Text>{renderItemLabel(item)}</Text>

View File

@@ -0,0 +1,93 @@
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

@@ -31,6 +31,25 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: sf_carousel, isFetching: l1 } = useQuery({
queryKey: ["sf_carousel", user?.Id, settings?.mediaListCollectionIds],
queryFn: async () => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_carousel"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items?.[0].Id || null;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const onPressPagination = (index: number) => {
ref.current?.scrollTo({
/**
@@ -42,40 +61,20 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
});
};
const { data: mediaListCollection, isLoading: l1 } = useQuery<string | null>({
queryKey: ["mediaListCollection", user?.Id],
queryFn: async () => {
if (!api || !user?.Id) return null;
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["medialist", "promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
const id = response.data.Items?.find((c) => c.Name === "sf_carousel")?.Id;
return id || null;
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
});
const { data: popularItems, isLoading: l2 } = useQuery<BaseItemDto[]>({
const { data: popularItems, isFetching: l2 } = useQuery<BaseItemDto[]>({
queryKey: ["popular", user?.Id],
queryFn: async () => {
if (!api || !user?.Id || !mediaListCollection) return [];
if (!api || !user?.Id || !sf_carousel) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
parentId: mediaListCollection,
parentId: sf_carousel,
limit: 10,
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && !!mediaListCollection,
enabled: !!api && !!user?.Id && !!sf_carousel,
staleTime: 0,
});
@@ -94,7 +93,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
<View className="flex flex-col items-center" {...props}>
<Carousel
autoPlay={true}
autoPlayInterval={2000}
autoPlayInterval={3000}
loop={true}
ref={ref}
width={width}

View File

@@ -41,7 +41,7 @@ export const ScrollingCollectionList: React.FC<Props> = ({
key={index}
item={item}
className={`flex flex-col
${orientation === "vertical" ? "w-32" : "w-48"}
${orientation === "vertical" ? "w-28" : "w-44"}
`}
>
<View>

View File

@@ -13,6 +13,7 @@ import { InfiniteHorizontalScroll } from "../common/InfiniteHorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import MoviePoster from "../posters/MoviePoster";
import { useCallback } from "react";
import { ItemCardText } from "../ItemCardText";
interface Props extends ViewProps {
collection: BaseItemDto;
@@ -56,11 +57,12 @@ export const MediaListSection: React.FC<Props> = ({ collection, ...props }) => {
key={index}
item={item}
className={`flex flex-col
${"w-32"}
${"w-28"}
`}
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}

View File

@@ -12,7 +12,13 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
import { useActionSheet } from "@expo/react-native-action-sheet";
import ios from "@/utils/profiles/ios";
@@ -35,14 +41,40 @@ export const SongsListItem: React.FC<Props> = ({
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const [, setPlaying] = useAtom(playingAtom);
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
const openSelect = () => {
play("device");
return;
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") => {
@@ -61,16 +93,37 @@ export const SongsListItem: React.FC<Props> = ({
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
sessionData,
deviceProfile: ios,
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
});
if (!url || !item) return;
setCp({
item,
playbackUrl: url,
});
setPlaying(true);
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 {
setCp({
item,
playbackUrl: url,
});
setPlaying(true);
}
};
return (

View File

@@ -10,11 +10,16 @@ import Poster from "../posters/Poster";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { router } from "expo-router";
import { router, usePathname } from "expo-router";
import { useSettings } from "@/utils/atoms/settings";
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>
@@ -23,7 +28,10 @@ export const CastAndCrew = ({ item }: { item: BaseItemDto }) => {
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
// TODO: Navigate to person
if (settings?.searchEngine === "Marlin")
router.push(`/search?q=${item.Name}&prev=${pathname}`);
else
Linking.openURL(`https://www.google.com/search?q=${item.Name}`);
}}
key={item.Id}
className="flex flex-col w-32"

View File

@@ -52,7 +52,7 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
router.push(`/(auth)/items/${item.Id}`);
}}
key={item.Id}
className="flex flex-col w-32"
className="flex flex-col w-44"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />

View File

@@ -1,25 +1,33 @@
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 { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { DownloadItem } from "../DownloadItem";
import { Loader } from "../Loader";
import { Text } from "../common/Text";
type Props = {
item: BaseItemDto;
};
export const seasonIndexAtom = atom<number>(1);
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 [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
const [seasonIndexState, setSeasonIndexState] = useAtom(seasonIndexAtom);
const seasonIndex = seasonIndexState[item.Id ?? ""];
const router = useRouter();
@@ -47,13 +55,24 @@ 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 } = useQuery({
const { data: episodes, isFetching } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => {
if (!api || !user?.Id || !item.Id) return [];
@@ -77,9 +96,55 @@ 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]);
return (
<View className="mb-2">
{episodes && (
<View
style={{
minHeight: 144 * nrOfEpisodes,
}}
>
<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>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
{seasons?.map((season: any) => (
<DropdownMenu.Item
key={season.Name}
onSelect={() => {
setSeasonIndexState((prev) => ({
...prev,
[item.Id ?? ""]: season.IndexNumber,
}));
}}
>
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/* Old View. Might have a setting later to manually select view. */}
{/* {episodes && (
<View className="mt-4">
<HorizontalScroll<BaseItemDto>
data={episodes}
@@ -97,7 +162,56 @@ 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,11 +1,15 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { DownloadOptions, useSettings } from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
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();
@@ -13,26 +17,27 @@ export const SettingToggles: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState<string>("");
const queryClient = useQueryClient();
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
} = useQuery({
queryKey: ["mediaListCollections", user?.Id],
queryKey: ["sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["medialist", "promoted"],
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
const ids =
response.data.Items?.filter((c) => c.Name !== "sf_carousel") ?? [];
return ids;
return response.data.Items ?? [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 0,
@@ -53,6 +58,46 @@ export const SettingToggles: React.FC = () => {
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>
@@ -68,6 +113,23 @@ export const SettingToggles: React.FC = () => {
}
/>
</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">
@@ -151,6 +213,7 @@ export const SettingToggles: React.FC = () => {
onValueChange={(value) => updateSettings({ forceDirectPlay: value })}
/>
</View>
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
@@ -164,6 +227,131 @@ export const SettingToggles: React.FC = () => {
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

@@ -21,13 +21,13 @@
}
},
"production": {
"channel": "0.6.1",
"channel": "0.8.1",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.6.1",
"channel": "0.8.1",
"android": {
"buildType": "apk",
"image": "latest"

117
hooks/useDownloadMedia.ts Normal file
View File

@@ -0,0 +1,117 @@
import { useCallback, useRef, useState } from "react";
import { useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runningProcesses } from "@/utils/atoms/downloads";
/**
* Custom hook for downloading media using the Jellyfin API.
*
* @param api - The Jellyfin API instance
* @param userId - The user ID
* @returns An object with download-related functions and state
*/
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
const [isDownloading, setIsDownloading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [_, setProgress] = useAtom(runningProcesses);
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
null,
);
const downloadMedia = useCallback(
async (item: BaseItemDto | null): Promise<boolean> => {
if (!item?.Id || !api || !userId) {
setError("Invalid item or API");
return false;
}
setIsDownloading(true);
setError(null);
setProgress({ item, progress: 0 });
try {
const filename = item.Id;
const fileUri = `${FileSystem.documentDirectory}${filename}`;
const url = `${api.basePath}/Items/${item.Id}/File`;
downloadResumableRef.current = FileSystem.createDownloadResumable(
url,
fileUri,
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
(downloadProgress) => {
const currentProgress =
downloadProgress.totalBytesWritten /
downloadProgress.totalBytesExpectedToWrite;
setProgress({ item, progress: currentProgress * 100 });
},
);
const res = await downloadResumableRef.current.downloadAsync();
if (!res?.uri) {
throw new Error("Download failed: No URI returned");
}
await updateDownloadedFiles(item);
setIsDownloading(false);
setProgress(null);
return true;
} catch (error) {
console.error("Error downloading media:", error);
setError("Failed to download media");
setIsDownloading(false);
setProgress(null);
return false;
}
},
[api, userId, setProgress],
);
const cancelDownload = useCallback(async (): Promise<void> => {
if (!downloadResumableRef.current) return;
try {
await downloadResumableRef.current.pauseAsync();
setIsDownloading(false);
setError("Download cancelled");
setProgress(null);
downloadResumableRef.current = null;
} catch (error) {
console.error("Error cancelling download:", error);
setError("Failed to cancel download");
}
}, [setProgress]);
return { downloadMedia, isDownloading, error, cancelDownload };
};
/**
* Updates the list of downloaded files in AsyncStorage.
*
* @param item - The item to add to the downloaded files list
*/
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
try {
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) ?? "[]",
);
const updatedFiles = [
...currentFiles.filter((file) => file.Id !== item.Id),
item,
];
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles),
);
} catch (error) {
console.error("Error updating downloaded files:", error);
}
}

84
hooks/useFiles.ts Normal file
View File

@@ -0,0 +1,84 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQueryClient } from "@tanstack/react-query";
import * as FileSystem from "expo-file-system";
/**
* Custom hook for managing downloaded files.
* @returns An object with functions to delete individual files and all files.
*/
export const useFiles = () => {
const queryClient = useQueryClient();
/**
* Deletes all downloaded files and clears the download record.
*/
const deleteAllFiles = async (): Promise<void> => {
const directoryUri = FileSystem.documentDirectory;
if (!directoryUri) {
console.error("Document directory is undefined");
return;
}
try {
const fileNames = await FileSystem.readDirectoryAsync(directoryUri);
await Promise.all(
fileNames.map((item) =>
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
idempotent: true,
}),
),
);
await AsyncStorage.removeItem("downloaded_files");
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
} catch (error) {
console.error("Failed to delete all files:", error);
}
};
/**
* Deletes a specific file and updates the download record.
* @param id - The ID of the file to delete.
*/
const deleteFile = async (id: string): Promise<void> => {
if (!id) {
console.error("Invalid file ID");
return;
}
try {
await FileSystem.deleteAsync(
`${FileSystem.documentDirectory}/${id}.mp4`,
{ idempotent: true },
);
const currentFiles = await getDownloadedFiles();
const updatedFiles = currentFiles.filter((f) => f.Id !== id);
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles),
);
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
} catch (error) {
console.error(`Failed to delete file with ID ${id}:`, error);
}
};
return { deleteFile, deleteAllFiles };
};
/**
* Retrieves the list of downloaded files from AsyncStorage.
* @returns An array of BaseItemDto objects representing downloaded files.
*/
async function getDownloadedFiles(): Promise<BaseItemDto[]> {
try {
const filesJson = await AsyncStorage.getItem("downloaded_files");
return filesJson ? JSON.parse(filesJson) : [];
} catch (error) {
console.error("Failed to retrieve downloaded files:", error);
return [];
}
}

147
hooks/useRemuxHlsToMp4.ts Normal file
View File

@@ -0,0 +1,147 @@
import { useCallback } from "react";
import { useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
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";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
*
* @param url - The URL of the HLS stream
* @param item - The BaseItemDto object representing the media item
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const [_, setProgress] = useAtom(runningProcesses);
const queryClient = useQueryClient();
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
throw new Error("Item must have an Id and Name");
}
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
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}`;
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`
);
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(null);
throw error; // Re-throw the error to propagate it to the caller
}
},
[output, item, setProgress]
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProgress(null);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
);
}, [item.Name, setProgress]);
return { startRemuxing, cancelRemuxing };
};
/**
* Updates the list of downloaded files in AsyncStorage.
*
* @param item - The item to add to the downloaded files list
*/
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
try {
const currentFiles: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
);
const updatedFiles = [
...currentFiles.filter((i) => i.Id !== item.Id),
item,
];
await AsyncStorage.setItem(
"downloaded_files",
JSON.stringify(updatedFiles)
);
} catch (error) {
console.error("Error updating downloaded files:", error);
writeToLog(
"ERROR",
`Failed to update downloaded files for item: ${item.Name}`
);
}
}

View File

@@ -15,14 +15,15 @@
"preset": "jest-expo"
},
"dependencies": {
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.2",
"@gorhom/bottom-sheet": "^4",
"@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/netinfo": "11.3.1",
"@react-native-menu/menu": "^1.1.2",
"@react-native-tvos/config-tv": "^0.0.10",
"@react-navigation/native": "^6.0.2",
"@shopify/flash-list": "1.6.4",
"@tanstack/react-query": "^5.51.16",
@@ -36,27 +37,33 @@
"expo-dev-client": "~4.0.23",
"expo-device": "~6.0.2",
"expo-font": "~12.0.9",
"expo-image": "~1.12.13",
"expo-haptics": "~13.0.1",
"expo-image": "~1.12.14",
"expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
"expo-router": "~3.5.23",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.22",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.1",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "npm:react-native-tvos@latest",
"react-native": "0.74.5",
"react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25",
"react-native-gesture-handler": "~2.16.1",
"react-native-get-random-values": "^1.11.0",
"react-native-ios-utilities": "^4.5.0",
"react-native-google-cast": "^4.8.2",
"react-native-ios-context-menu": "^2.5.1",
"react-native-ios-utilities": "^4.4.5",
"react-native-reanimated": "~3.10.1",
"react-native-reanimated-carousel": "4.0.0-alpha.12",
"react-native-safe-area-context": "4.10.5",
@@ -69,6 +76,7 @@
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.3",
"uuid": "^10.0.0",
"zeego": "^1.10.0",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -77,16 +85,9 @@
"@types/react": "~18.2.45",
"@types/react-test-renderer": "^18.0.7",
"jest": "^29.2.1",
"jest-expo": "~51.0.3",
"jest-expo": "~51.0.4",
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
},
"expo": {
"install": {
"exclude": [
"react-native"
]
}
},
"private": true
}

View File

@@ -1,6 +1,13 @@
import {
currentlyPlayingItemAtom,
playingAtom,
showCurrentlyPlayingBarAtom,
} from "@/utils/atoms/playState";
import { Api, Jellyfin } from "@jellyfin/sdk";
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useMutation } from "@tanstack/react-query";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation, useQuery } from "@tanstack/react-query";
import axios from "axios";
import { router, useSegments } from "expo-router";
import { atom, useAtom } from "jotai";
import React, {
@@ -19,6 +26,7 @@ interface Server {
export const apiAtom = atom<Api | null>(null);
export const userAtom = atom<UserDto | null>(null);
export const wsAtom = atom<WebSocket | null>(null);
interface JellyfinContextValue {
discoverServers: (url: string) => Promise<Server[]>;
@@ -33,10 +41,11 @@ const JellyfinContext = createContext<JellyfinContextValue | undefined>(
);
const getOrSetDeviceId = async () => {
let deviceId = null;
let deviceId = await AsyncStorage.getItem("deviceId");
if (!deviceId) {
deviceId = uuid.v4() as string;
await AsyncStorage.setItem("deviceId", deviceId);
}
return deviceId;
@@ -46,6 +55,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
const [isConnected, setIsConnected] = useState<boolean>(false);
useEffect(() => {
(async () => {
@@ -53,10 +64,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.6.1" },
clientInfo: { name: "Streamyfin", version: "0.8.1" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
setDeviceId(id);
})();
}, []);
@@ -77,6 +89,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
if (!apiInstance?.basePath) throw new Error("Failed to connect");
setApi(apiInstance);
await AsyncStorage.setItem("serverUrl", server.address);
},
onError: (error) => {
console.error("Failed to set server:", error);
@@ -85,6 +98,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const removeServerMutation = useMutation({
mutationFn: async () => {
await AsyncStorage.removeItem("serverUrl");
setApi(null);
},
onError: (error) => {
@@ -102,13 +116,40 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
}) => {
if (!api || !jellyfin) throw new Error("API not initialized");
const auth = await api.authenticateUserByName(username, password);
try {
const auth = await api.authenticateUserByName(username, password);
if (auth.data.AccessToken && auth.data.User) {
setUser(auth.data.User);
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
} else {
throw new Error("Invalid username or password");
if (auth.data.AccessToken && auth.data.User) {
setUser(auth.data.User);
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
await AsyncStorage.setItem("token", auth.data?.AccessToken);
}
} catch (error) {
if (axios.isAxiosError(error)) {
console.log("Axios error", error.response?.status);
switch (error.response?.status) {
case 401:
throw new Error("Invalid username or password");
case 403:
throw new Error("User does not have permission to log in");
case 408:
throw new Error(
"Server is taking too long to respond, try again later"
);
case 429:
throw new Error(
"Server received too many requests, try again later"
);
case 500:
throw new Error("There is a server error");
default:
throw new Error(
"An unexpected error occurred. Did you enter the server URL correctly?"
);
}
}
throw error;
}
},
onError: (error) => {
@@ -118,6 +159,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const logoutMutation = useMutation({
mutationFn: async () => {
await AsyncStorage.removeItem("token");
setUser(null);
},
onError: (error) => {
@@ -125,6 +167,36 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
},
});
const { isLoading, isFetching } = useQuery({
queryKey: [
"initializeJellyfin",
user?.Id,
api?.basePath,
jellyfin?.clientInfo,
],
queryFn: async () => {
try {
const token = await AsyncStorage.getItem("token");
const serverUrl = await AsyncStorage.getItem("serverUrl");
const user = JSON.parse(
(await AsyncStorage.getItem("user")) as string
) as UserDto;
if (serverUrl && token && user.Id && jellyfin) {
const apiInstance = jellyfin.createApi(serverUrl, token);
setApi(apiInstance);
setUser(user);
}
return true;
} catch (e) {
console.error(e);
}
},
staleTime: 0,
enabled: !user?.Id || !api || !jellyfin,
});
const contextValue: JellyfinContextValue = {
discoverServers,
setServer: (server) => setServerMutation.mutateAsync(server),
@@ -134,7 +206,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
logout: () => logoutMutation.mutateAsync(),
};
useProtectedRoute(user);
useProtectedRoute(user, isLoading || isFetching);
return (
<JellyfinContext.Provider value={contextValue}>

View File

@@ -0,0 +1,14 @@
import React, { createContext } from "react";
import { useJobProcessor } from "@/utils/atoms/queue";
const JobQueueContext = createContext(null);
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
useJobProcessor();
return (
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
);
};

View File

@@ -0,0 +1,281 @@
import { useQuery } from "@tanstack/react-query";
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useSettings } from "@/utils/atoms/settings";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import {
BaseItemDto,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
import { getDeviceId } from "@/utils/device";
import * as Linking from "expo-linking";
import { Platform } from "react-native";
type CurrentlyPlayingState = {
url: string;
item: BaseItemDto;
};
interface PlaybackContextType {
sessionData: PlaybackInfoResponse | null | undefined;
currentlyPlaying: CurrentlyPlayingState | null;
videoRef: React.MutableRefObject<VideoRef | null>;
isPlaying: boolean;
isFullscreen: boolean;
progressTicks: number | null;
playVideo: () => void;
pauseVideo: () => void;
stopPlayback: () => void;
presentFullscreenPlayer: () => void;
dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void;
onProgress: (data: OnProgressData) => void;
setCurrentlyPlayingState: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
}
const PlaybackContext = createContext<PlaybackContextType | null>(null);
export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const videoRef = useRef<VideoRef | null>(null);
const [settings] = useSettings();
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [progressTicks, setProgressTicks] = useState<number | null>(0);
const [currentlyPlaying, setCurrentlyPlaying] =
useState<CurrentlyPlayingState | null>(null);
// WS
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", currentlyPlaying?.item.Id, user?.Id, api],
queryFn: async () => {
if (!currentlyPlaying?.item.Id) return null;
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
itemId: currentlyPlaying?.item.Id,
userId: user?.Id,
});
return playbackData.data;
},
enabled: !!currentlyPlaying?.item.Id && !!api && !!user?.Id,
});
const { data: deviceId } = useQuery({
queryKey: ["deviceId", api],
queryFn: getDeviceId,
});
const setCurrentlyPlayingState = useCallback(
(state: CurrentlyPlayingState | null) => {
const vlcLink = "vlc://" + state?.url;
console.log(vlcLink, settings?.openInVLC, Platform.OS === "ios");
if (vlcLink && settings?.openInVLC) {
Linking.openURL("vlc://" + state?.url || "");
return;
}
if (state) {
setCurrentlyPlaying(state);
setIsPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault)
presentFullscreenPlayer();
} else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
setIsPlaying(false);
}
},
[settings]
);
// Define control methods
const playVideo = useCallback(() => {
videoRef.current?.resume();
setIsPlaying(true);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: sessionData?.PlaySessionId,
IsPaused: true,
});
}, [
api,
currentlyPlaying?.item.Id,
sessionData?.PlaySessionId,
progressTicks,
]);
const pauseVideo = useCallback(() => {
videoRef.current?.pause();
setIsPlaying(false);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: progressTicks ? progressTicks : 0,
sessionId: sessionData?.PlaySessionId,
IsPaused: false,
});
}, [sessionData?.PlaySessionId, currentlyPlaying?.item.Id, progressTicks]);
const stopPlayback = useCallback(async () => {
await reportPlaybackStopped({
api,
itemId: currentlyPlaying?.item?.Id,
sessionId: sessionData?.PlaySessionId,
positionTicks: progressTicks ? progressTicks : 0,
});
setCurrentlyPlayingState(null);
}, [currentlyPlaying, sessionData, progressTicks]);
const onProgress = useCallback(
({ currentTime }: OnProgressData) => {
const ticks = currentTime * 10000000;
setProgressTicks(ticks);
reportPlaybackProgress({
api,
itemId: currentlyPlaying?.item.Id,
positionTicks: ticks,
sessionId: sessionData?.PlaySessionId,
IsPaused: !isPlaying,
});
},
[sessionData?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying]
);
const presentFullscreenPlayer = useCallback(() => {
videoRef.current?.presentFullscreenPlayer();
setIsFullscreen(true);
}, []);
const dismissFullscreenPlayer = useCallback(() => {
videoRef.current?.dismissFullscreenPlayer();
setIsFullscreen(false);
}, []);
useEffect(() => {
if (!deviceId || !api?.accessToken) return;
const url = `wss://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
console.log("WS", url);
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
newWebSocket.onopen = () => {
setIsConnected(true);
// Start sending "KeepAlive" message every 30 seconds
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
console.log("KeepAlive message sent");
}
}, 30000);
};
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
newWebSocket.onclose = (e) => {
console.log("WebSocket connection closed:", e.reason);
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user]);
useEffect(() => {
if (!ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
// On PlayPause
if (command === "PlayPause") {
console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo();
else playVideo();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
}
};
}, [ws, stopPlayback, playVideo, pauseVideo]);
return (
<PlaybackContext.Provider
value={{
onProgress,
progressTicks,
setIsPlaying,
setIsFullscreen,
isFullscreen,
isPlaying,
currentlyPlaying,
sessionData,
videoRef,
playVideo,
setCurrentlyPlayingState,
pauseVideo,
stopPlayback,
presentFullscreenPlayer,
dismissFullscreenPlayer,
}}
>
{children}
</PlaybackContext.Provider>
);
};
export const usePlayback = () => {
const context = useContext(PlaybackContext);
if (!context) {
throw new Error("usePlayback must be used within a PlaybackProvider");
}
return context;
};

View File

@@ -13,7 +13,9 @@ export const sortOptions: {
{ key: "SortName", value: "Name" },
{ key: "CommunityRating", value: "Community Rating" },
{ key: "CriticRating", value: "Critics Rating" },
{ key: "DateLastContentAdded", value: "Content Added" },
{ key: "DateCreated", value: "Date Added" },
// Only works for shows (last episode added) keeping for future ref.
// { key: "DateLastContentAdded", value: "Content Added" },
{ key: "DatePlayed", value: "Date Played" },
{ key: "PlayCount", value: "Play Count" },
{ key: "ProductionYear", value: "Production Year" },
@@ -23,7 +25,8 @@ export const sortOptions: {
{ key: "StartDate", value: "Start Date" },
{ key: "IsUnplayed", value: "Is Unplayed" },
{ key: "IsPlayed", value: "Is Played" },
{ key: "VideoBitRate", value: "Video Bit Rate" },
// Broken in JF
// { key: "VideoBitRate", value: "Video Bit Rate" },
{ key: "AirTime", value: "Air Time" },
{ key: "Studio", value: "Studio" },
{ key: "IsFavoriteOrLiked", value: "Is Favorite Or Liked" },

View File

@@ -1,4 +1,28 @@
import { atom, useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect } from "react";
export type DownloadQuality = "original" | "high" | "low";
export type DownloadOption = {
label: string;
value: DownloadQuality;
};
export const DownloadOptions: DownloadOption[] = [
{
label: "Original quality",
value: "original",
},
{
label: "High quality",
value: "high",
},
{
label: "Small file size",
value: "low",
},
];
type Settings = {
autoRotate?: boolean;
@@ -8,29 +32,72 @@ type Settings = {
deviceProfile?: "Expo" | "Native" | "Old";
forceDirectPlay?: boolean;
mediaListCollectionIds?: string[];
searchEngine: "Marlin" | "Jellyfin";
marlinServerUrl?: string;
openInVLC?: boolean;
downloadQuality?: DownloadOption;
};
// Default settings
const defaultSettings: Settings = {
autoRotate: true,
forceLandscapeInVideoPlayer: false,
openFullScreenVideoPlayerByDefault: true,
usePopularPlugin: false,
deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [],
/**
*
* The settings atom is a Jotai atom that stores the user's settings.
* It is initialized with a default value of null, which indicates that the settings have not been loaded yet.
* The settings are loaded from AsyncStorage when the atom is read for the first time.
*
*/
const loadSettings = async (): Promise<Settings> => {
const defaultValues: Settings = {
autoRotate: true,
forceLandscapeInVideoPlayer: false,
openFullScreenVideoPlayerByDefault: false,
usePopularPlugin: false,
deviceProfile: "Expo",
forceDirectPlay: false,
mediaListCollectionIds: [],
searchEngine: "Jellyfin",
marlinServerUrl: "",
openInVLC: false,
downloadQuality: DownloadOptions[0],
};
try {
const jsonValue = await AsyncStorage.getItem("settings");
const loadedValues: Partial<Settings> =
jsonValue != null ? JSON.parse(jsonValue) : {};
return { ...defaultValues, ...loadedValues };
} catch (error) {
console.error("Failed to load settings:", error);
return defaultValues;
}
};
// Create an atom to store the settings in memory, initialized with default settings
const settingsAtom = atom<Settings>(defaultSettings);
// Utility function to save settings to AsyncStorage
const saveSettings = async (settings: Settings) => {
const jsonValue = JSON.stringify(settings);
await AsyncStorage.setItem("settings", jsonValue);
};
// A hook to manage settings, providing a way to update them
// Create an atom to store the settings in memory
const settingsAtom = atom<Settings | null>(null);
// A hook to manage settings, loading them on initial mount and providing a way to update them
export const useSettings = () => {
const [settings, setSettings] = useAtom(settingsAtom);
const updateSettings = (update: Partial<Settings>) => {
const newSettings = { ...settings, ...update };
setSettings(newSettings);
useEffect(() => {
if (settings === null) {
loadSettings().then(setSettings);
}
}, [settings, setSettings]);
const updateSettings = async (update: Partial<Settings>) => {
if (settings) {
const newSettings = { ...settings, ...update };
setSettings(newSettings);
await saveSettings(newSettings);
}
};
return [settings, updateSettings] as const;

19
utils/device.ts Normal file
View File

@@ -0,0 +1,19 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import uuid from "react-native-uuid";
export const getOrSetDeviceId = async () => {
let deviceId = await AsyncStorage.getItem("deviceId");
if (!deviceId) {
deviceId = uuid.v4() as string;
await AsyncStorage.setItem("deviceId", deviceId);
}
return deviceId;
};
export const getDeviceId = async () => {
let deviceId = await AsyncStorage.getItem("deviceId");
return deviceId || null;
};

View File

@@ -1,12 +1,13 @@
import { Api } from "@jellyfin/sdk";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
import { postCapabilities } from "../session/capabilities";
interface ReportPlaybackProgressParams {
api: Api;
sessionId: string;
itemId: string;
positionTicks: number;
api?: Api | null;
sessionId?: string | null;
itemId?: string | null;
positionTicks?: number | null;
IsPaused?: boolean;
}
/**
@@ -20,25 +21,44 @@ export const reportPlaybackProgress = async ({
sessionId,
itemId,
positionTicks,
IsPaused = false,
}: ReportPlaybackProgressParams): Promise<void> => {
console.info(
"Reporting playback progress:",
sessionId,
itemId,
positionTicks,
);
if (!api || !sessionId || !itemId || !positionTicks) {
console.error(
"Missing required parameter",
sessionId,
itemId,
positionTicks
);
return;
}
console.info("reportPlaybackProgress ~ IsPaused", IsPaused);
try {
await postCapabilities({
api,
itemId,
sessionId,
});
} catch (error) {
console.error("Failed to post capabilities.", error);
throw new Error("Failed to post capabilities.");
}
try {
await api.axiosInstance.post(
`${api.basePath}/Sessions/Playing/Progress`,
{
ItemId: itemId,
PlaySessionId: sessionId,
IsPaused: false,
IsPaused,
PositionTicks: Math.round(positionTicks),
CanSeek: true,
MediaSourceId: itemId,
EventName: "timeupdate",
},
{ headers: getAuthHeaders(api) },
{ headers: getAuthHeaders(api) }
);
} catch (error) {
console.error(error);

View File

@@ -41,12 +41,15 @@ export const reportPlaybackStopped = async ({
return;
}
console.log("reportPlaybackStopped ~", { sessionId, itemId });
try {
const url = `${api.basePath}/PlayingItems/${itemId}`;
const params = {
playSessionId: sessionId,
positionTicks: Math.round(positionTicks),
mediaSourceId: itemId,
MediaSourceId: itemId,
IsPaused: true,
};
const headers = getAuthHeaders(api);
@@ -58,7 +61,7 @@ export const reportPlaybackStopped = async ({
console.error(
"Failed to report playback progress",
error.message,
error.response?.data,
error.response?.data
);
} else {
console.error("Failed to report playback progress", error);

View File

@@ -0,0 +1,48 @@
import { Api } from "@jellyfin/sdk";
import {
SessionApi,
SessionApiPostCapabilitiesRequest,
} from "@jellyfin/sdk/lib/generated-client/api/session-api";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {
api: Api | null | undefined;
itemId: string | null | undefined;
sessionId: string | null | undefined;
}
/**
* Marks a media item as not played for a specific user.
*
* @param params - The parameters for marking an item as not played
* @returns A promise that resolves to true if the operation was successful, false otherwise
*/
export const postCapabilities = async ({
api,
itemId,
sessionId,
}: PostCapabilitiesParams): Promise<void> => {
if (!api || !itemId || !sessionId) {
throw new Error("Missing required parameters");
}
try {
const r = await api.axiosInstance.post(
api.basePath + "/Sessions/Capabilities/Full",
{
playableMediaTypes: ["Audio", "Video", "Audio"],
supportedCommands: ["PlayState", "Play"],
supportsMediaControl: true,
id: sessionId,
},
{
headers: getAuthHeaders(api),
}
);
} catch (error: any | AxiosError) {
console.log("Failed to mark as not played", error);
throw new Error("Failed to mark as not played");
}
};

View File

@@ -1,4 +1,4 @@
import { atom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
type LogLevel = "INFO" | "WARN" | "ERROR";
@@ -10,7 +10,8 @@ interface LogEntry {
data?: any;
}
const logsAtom = atom([]);
const asyncStorage = createJSONStorage(() => AsyncStorage);
const logsAtom = atomWithStorage("logs", [], asyncStorage);
export const writeToLog = async (
level: LogLevel,
@@ -24,16 +25,23 @@ export const writeToLog = async (
data: data,
};
const logs: LogEntry[] = [];
const currentLogs = await AsyncStorage.getItem("logs");
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
logs.push(newEntry);
const maxLogs = 100;
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
};
export const readFromLog = async (): Promise<LogEntry[]> => {
return [];
const logs = await AsyncStorage.getItem("logs");
return logs ? JSON.parse(logs) : [];
};
export const clearLogs = async () => {};
export const clearLogs = async () => {
await AsyncStorage.removeItem("logs");
};
export default logsAtom;

9201
yarn.lock

File diff suppressed because it is too large Load Diff