Compare commits

..

116 Commits

Author SHA1 Message Date
Fredrik Burmester
559d8474bc wip 2024-10-03 18:26:59 +02:00
Fredrik Burmester
83548da2c5 wip 2024-10-03 07:46:09 +02:00
Fredrik Burmester
b21a1cd18e wip 2024-10-03 07:37:37 +02:00
Fredrik Burmester
60981504fc wip 2024-10-02 22:07:13 +02:00
Fredrik Burmester
1df7d8e8fe wip 2024-10-01 22:59:33 +02:00
Fredrik Burmester
0acc1f03f0 wip 2024-10-01 17:42:09 +02:00
Fredrik Burmester
dd1f02a13b fix: manual download button 2024-10-01 14:16:02 +02:00
Fredrik Burmester
c5c5252b89 fix: music screen fixes #149 2024-10-01 10:12:51 +02:00
Fredrik Burmester
679d6078e2 fix: remove failed process 2024-09-30 22:49:47 +02:00
Fredrik Burmester
329a75a047 fix: download path on android 2024-09-30 22:11:53 +02:00
Fredrik Burmester
5f91712126 fix: keep track of local download progress 2024-09-30 20:48:39 +02:00
Fredrik Burmester
7ce2c90376 wip 2024-09-30 16:34:54 +02:00
Fredrik Burmester
0263ad6109 fix: bottom padding 2024-09-29 23:27:16 +02:00
Fredrik Burmester
05b7872022 fix: use sheet instead of context 2024-09-29 23:25:10 +02:00
Fredrik Burmester
9458d113de wip 2024-09-29 22:56:22 +02:00
Fredrik Burmester
12cb6d4963 wip 2024-09-29 19:43:17 +02:00
Fredrik Burmester
1c2578477a fix: app not reporting playback started on first start 2024-09-29 14:48:51 +02:00
Fredrik Burmester
c7e10a13b5 fix: download progress not showing 2024-09-29 13:28:36 +02:00
Fredrik Burmester
31dbd84bec fix: add back speed to normal downloads 2024-09-29 12:03:37 +02:00
Fredrik Burmester
b6c6bac06a fix: queue now work for normal downloads 2024-09-29 11:59:37 +02:00
Fredrik Burmester
b0f7cfd013 wip 2024-09-29 11:02:13 +02:00
Fredrik Burmester
456048a92c wip 2024-09-29 08:05:22 +02:00
Fredrik Burmester
ff88c45d43 wip 2024-09-28 20:24:39 +02:00
Fredrik Burmester
ddcb410df6 wip 2024-09-28 19:57:56 +02:00
Fredrik Burmester
b3a938b53a wip 2024-09-28 18:32:08 +02:00
Fredrik Burmester
73c43d31ee wip 2024-09-28 16:13:01 +02:00
Fredrik Burmester
41a23d3437 wip 2024-09-28 15:45:00 +02:00
Fredrik Burmester
79020c357f wip 2024-09-27 18:44:41 +02:00
Fredrik Burmester
a386c3a47c wip 2024-09-27 18:18:10 +02:00
Fredrik Burmester
a46737442d wip 2024-09-27 17:35:51 +02:00
Fredrik Burmester
41d209f3b7 wip 2024-09-27 15:56:42 +02:00
Fredrik Burmester
d7eb25edf4 Update README.md 2024-09-26 13:34:19 +02:00
Fredrik Burmester
d672882c4b Update README.md 2024-09-26 13:34:03 +02:00
Fredrik Burmester
09dafea4ad chore 2024-09-25 08:28:36 +02:00
Fredrik Burmester
8936a559de feat: fade in player 2024-09-25 08:28:26 +02:00
Fredrik Burmester
7c10c467f3 chore 2024-09-25 07:42:41 +02:00
Fredrik Burmester
eff12b7350 fix: pip 2024-09-25 07:42:36 +02:00
Fredrik Burmester
94b6de6066 fix: open new full screen player 2024-09-25 07:42:32 +02:00
Fredrik Burmester
e82b154032 feat: skip credits 2024-09-24 20:05:04 +02:00
Fredrik Burmester
dd2a869929 chore: refactor 2024-09-24 20:04:58 +02:00
Fredrik Burmester
cd6158e141 chore 2024-09-24 20:04:50 +02:00
Fredrik Burmester
7fd232614b fix: refactor 2024-09-24 18:16:54 +02:00
Fredrik Burmester
af0a5f54d8 fix: design 2024-09-24 18:16:45 +02:00
Fredrik Burmester
3151812325 chore 2024-09-24 18:16:38 +02:00
Fredrik Burmester
9aa0dc0a3d wip: full screen player 2024-09-24 18:16:26 +02:00
Fredrik Burmester
9fcff04c0d chore: hide button 2024-09-24 18:16:00 +02:00
Fredrik Burmester
d1b6a265a1 fix: #135 2024-09-24 18:15:52 +02:00
Fredrik Burmester
ff1decfe2c feat: select skip/rewind time + refactor video player 2024-09-22 23:05:13 +02:00
Fredrik Burmester
a023c91877 feat: add genres to movies and episodes 2024-09-20 07:13:47 +02:00
Fredrik Burmester
27acd5287f fix: smaller card 2024-09-19 22:21:01 +02:00
Fredrik Burmester
ae73dab46d fix: design improvements 2024-09-19 22:17:40 +02:00
Fredrik Burmester
11f53630b5 feat: more from this actor 2024-09-19 22:17:29 +02:00
Fredrik Burmester
b7465a94e9 chore 2024-09-19 21:23:38 +02:00
Fredrik Burmester
cc97acbd4f feat: title selectable 2024-09-19 21:23:32 +02:00
Fredrik Burmester
abf7ec7d69 feat: include size 2024-09-19 21:23:22 +02:00
Fredrik Burmester
4dc9a6a0aa fix: initial load should be true 2024-09-19 21:23:04 +02:00
Fredrik Burmester
dd05ae89c3 fix: small changes 2024-09-19 16:51:13 +02:00
Fredrik Burmester
8a60adc6b2 fix: header size 2024-09-19 15:52:37 +02:00
Fredrik Burmester
51376cc8c1 fix: remove auto-hide controls for now 2024-09-19 13:00:20 +02:00
Fredrik Burmester
4eab1ebff6 chore 2024-09-18 08:42:26 +02:00
Fredrik Burmester
76388a408c fix: throttle 2024-09-18 08:42:23 +02:00
Fredrik Burmester
d3560c287c fix: more languages 2024-09-18 08:42:18 +02:00
Fredrik Burmester
da78ce898c fix: design 2024-09-18 08:42:09 +02:00
Fredrik Burmester
8a999a56a1 chore: component 2024-09-18 08:41:58 +02:00
Fredrik Burmester
669f8d7d1a fix: design 2024-09-18 08:41:48 +02:00
Fredrik Burmester
83bb5db335 fix: routing 2024-09-18 08:41:44 +02:00
Fredrik Burmester
7a5427099c fix: smaller titles 2024-09-18 08:41:28 +02:00
Fredrik Burmester
ee9b3de7d4 feat: set the server name as title 2024-09-17 19:34:32 +02:00
Fredrik Burmester
bcc28d7513 fix: accidental press when scrolling through the carousel 2024-09-17 19:34:18 +02:00
Fredrik Burmester
d093c028d2 chore 2024-09-17 19:33:46 +02:00
Fredrik Burmester
3032813234 Merge branch 'pr/129' 2024-09-17 18:55:06 +02:00
Fredrik Burmester
4a7d8721b3 chore 2024-09-17 18:50:26 +02:00
Fredrik Burmester
f45139ff90 Merge branch 'master' into feat/new-player-design 2024-09-17 18:49:44 +02:00
Fredrik Burmester
65579c88e5 wip 2024-09-17 18:49:11 +02:00
Fredrik Burmester
d716e42c20 wip 2024-09-17 08:53:10 +02:00
Fredrik Burmester
ffe1003710 wip 2024-09-17 08:31:47 +02:00
Fredrik Burmester
5c008f64b5 chore 2024-09-17 07:48:33 +02:00
Fredrik Burmester
721cd093f4 wip: orientation setting 2024-09-16 21:43:50 +02:00
Fredrik Burmester
3577aae7cc fix: improved server check
Included timeout, check url even if http is included, doc strings
2024-09-16 21:15:57 +02:00
Fredrik Burmester
402bdec5ab wip 2024-09-16 18:18:08 +02:00
simon
5e141f27c4 /System/Info/Public instead of /web 2024-09-16 16:37:45 +02:00
Fredrik Burmester
595120229f feat: see forward cached video length 2024-09-15 21:44:40 +02:00
Fredrik Burmester
09363bffdc wip 2024-09-15 21:36:59 +02:00
Fredrik Burmester
c3237571a8 fix: don't show fullscreen player on return from pip 2024-09-15 19:16:40 +02:00
simon
b67a4f1843 fixed some inconsistencies 2024-09-15 18:48:16 +02:00
simon
19a53da8a7 remove need for http/https 2024-09-15 18:45:26 +02:00
Fredrik Burmester
e3c4a291f0 wip 2024-09-15 18:39:20 +02:00
simon
25656cb7f1 remove need for http/https 2024-09-15 18:13:38 +02:00
simon
7f9c893560 remove need for http/https 2024-09-15 18:10:22 +02:00
Fredrik Burmester
ce2e5e0fb8 wip 2024-09-15 17:07:30 +02:00
Fredrik Burmester
c7703df3ce wip 2024-09-15 16:39:26 +02:00
simon
39880a6214 possible a good fix 2024-09-15 12:49:47 +02:00
Fredrik Burmester
b7629f6f2b wip: only full screen view, bar removed 2024-09-15 09:28:43 +02:00
Fredrik Burmester
409e2de6c8 wip: working small + full with transition 2024-09-15 08:56:54 +02:00
Fredrik Burmester
7cb67d73ec Merge branch 'master' into feat/new-player-design 2024-09-14 07:28:23 +03:00
Fredrik Burmester
1fe1438ecf chore: deps 2024-09-14 07:28:05 +03:00
Fredrik Burmester
611f5ae37b Merge branch 'master' of https://github.com/fredrikburmester/streamyfin 2024-09-14 07:22:34 +03:00
retardgerman
d2701254b3 Update bug_report.md 2024-09-13 14:08:15 +02:00
retardgerman
994dd44fc5 Update feature_request.md 2024-09-13 14:07:25 +02:00
Fredrik Burmester
f7e04dfa2d wip 2024-09-13 13:19:29 +03:00
Fredrik Burmester
cd126bb1c7 wip 2024-09-13 13:09:27 +03:00
Fredrik Burmester
ddbfb91260 wip 2024-09-13 13:07:55 +03:00
Fredrik Burmester
caac40c4b1 wip 2024-09-13 13:07:47 +03:00
Fredrik Burmester
2632feb3e8 wip 2024-09-13 12:24:25 +03:00
Fredrik Burmester
778447c1fd wip 2024-09-13 12:23:59 +03:00
Fredrik Burmester
5a1f555703 chore 2024-09-13 12:23:45 +03:00
Fredrik Burmester
2ed18d6588 feat: toast when starting download 2024-09-10 13:31:55 +03:00
Fredrik Burmester
c494b8e9f9 fix: add a test toast button 2024-09-10 13:31:36 +03:00
Fredrik Burmester
354fdd6791 fix: design 2024-09-10 13:31:20 +03:00
Fredrik Burmester
f48e0348ad fix: better play button color 2024-09-10 13:14:29 +03:00
Fredrik Burmester
23eaddf87c feat: add sonner/toast 2024-09-10 13:14:21 +03:00
Fredrik Burmester
a90dfb2805 fix: text 2024-09-10 10:27:15 +03:00
Fredrik Burmester
78d168050a chore 2024-09-10 10:26:16 +03:00
Fredrik Burmester
b92d55b9a0 chore 2024-09-10 10:25:52 +03:00
Fredrik Burmester
907f6193b5 fix: android header bugs 2024-09-10 10:25:48 +03:00
Fredrik Burmester
6f34f2e6a6 feat: allow authorizing quick connect closes #113 2024-09-09 09:04:35 +03:00
100 changed files with 5220 additions and 2086 deletions

View File

@@ -2,7 +2,7 @@
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
labels: '❌ bug'
assignees: ''
---

View File

@@ -2,7 +2,7 @@
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
labels: '✨ enhancement'
assignees: ''
---

View File

@@ -12,8 +12,8 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
</div>
## 🌟 Features
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 🚀 **Skp intro / credits support**
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.12.0",
"version": "0.16.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
@@ -19,7 +19,7 @@
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": ["audio"],
"UIBackgroundModes": ["audio", "fetch"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
@@ -33,7 +33,7 @@
},
"android": {
"jsEngine": "hermes",
"versionCode": 36,
"versionCode": 42,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},
@@ -43,11 +43,6 @@
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
]
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
"expo-font",
@@ -82,7 +77,7 @@
"expo-build-properties",
{
"ios": {
"deploymentTarget": "14.0"
"deploymentTarget": "15.6"
},
"android": {
"android": {

View File

@@ -1,7 +1,7 @@
import { Chromecast } from "@/components/Chromecast";
import { HeaderBackButton } from "@/components/common/HeaderBackButton";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router";
import { Platform, TouchableOpacity, View } from "react-native";
@@ -26,31 +26,21 @@ export default function IndexLayout() {
onPress={() => {
router.push("/(auth)/downloads");
}}
className="p-2"
>
<Feather name="download" color={"white"} size={22} />
</TouchableOpacity>
),
headerRight: () => (
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
router.push("/(auth)/syncplay");
}}
style={{
marginRight: 8,
}}
>
<Ionicons name="people" color={"white"} size={22} />
</TouchableOpacity>
<Chromecast />
<TouchableOpacity
onPress={() => {
router.push("/(auth)/settings");
}}
className="p-2 "
>
<View className="h-10 aspect-square flex items-center justify-center rounded">
<Feather name="settings" color={"white"} size={22} />
</View>
<Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</View>
),
@@ -68,16 +58,19 @@ export default function IndexLayout() {
title: "Settings",
}}
/>
<Stack.Screen
name="syncplay"
options={{
title: "Syncplay",
presentation: "modal",
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -1,32 +1,23 @@
import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard";
import { Loader } from "@/components/Loader";
import { runningProcesses } from "@/utils/atoms/downloads";
import { useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
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";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const downloads: React.FC = () => {
const [process, setProcess] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles } = useDownload();
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 [settings] = useSettings();
const movies = useMemo(
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
@@ -43,27 +34,8 @@ const downloads: React.FC = () => {
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]);
const insets = useSafeAreaInsets();
if (isLoading) {
return (
<View className="h-full flex flex-col items-center justify-center -mt-6">
<Loader />
</View>
);
}
return (
<ScrollView
contentContainerStyle={{
@@ -72,121 +44,80 @@ const downloads: React.FC = () => {
paddingBottom: 100,
}}
>
<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/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<View>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</Text>
</View>
<View className="py-4">
<View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === "remux" && (
<View className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Queue</Text>
<Text className="text-xs opacity-70 text-red-600">
Queue and downloads will be lost on app restart
</Text>
<View className="flex flex-col space-y-2 mt-2">
{queue.map((q) => (
<TouchableOpacity
onPress={() => {
setQueue((prev) => prev.filter((i) => i.id !== q.id));
}}
onPress={() =>
router.push(`/(auth)/items/page?id=${q.item.Id}`)
}
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
>
<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/page?id=${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>
<Text className="font-semibold">{q.item.Name}</Text>
<Text className="text-xs opacity-50">{q.item.Type}</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>
<TouchableOpacity
onPress={() => {
removeProcess(q.id);
setQueue((prev) => {
if (!prev) return [];
return [...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>
)}
<ActiveDownloads />
</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="flex flex-row items-center justify-between mb-2 px-4">
<Text className="text-lg 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} />
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{movies?.map((item: BaseItemDto) => (
<View className="mb-2 last:mb-0" key={item.Id}>
<MovieCard item={item} />
</View>
))}
</View>
))}
</ScrollView>
</View>
)}
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
<SeriesCard items={items} key={items[0].SeriesId} />
))}
{downloadedFiles?.length === 0 && (
<View className="flex px-4">
<Text className="opacity-50">No downloaded items</Text>
</View>
)}
</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

@@ -4,6 +4,7 @@ import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { Loader } from "@/components/Loader";
import { MediaListSection } from "@/components/medialists/MediaListSection";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { Ionicons } from "@expo/vector-icons";
@@ -23,8 +24,8 @@ import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Platform,
RefreshControl,
SafeAreaView,
ScrollView,
View,
} from "react-native";
@@ -138,18 +139,24 @@ export default function index() {
const refetch = useCallback(async () => {
setLoading(true);
await queryClient.refetchQueries({ queryKey: ["userViews"] });
await queryClient.refetchQueries({ queryKey: ["resumeItems"] });
await queryClient.refetchQueries({ queryKey: ["nextUp-all"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInMovies"] });
await queryClient.refetchQueries({ queryKey: ["recentlyAddedInTVShows"] });
await queryClient.refetchQueries({ queryKey: ["suggestions"] });
await queryClient.refetchQueries({
queryKey: ["sf_promoted"],
});
await queryClient.refetchQueries({
queryKey: ["sf_carousel"],
});
await queryClient.invalidateQueries();
// await queryClient.invalidateQueries({ queryKey: ["userViews"] });
// await queryClient.invalidateQueries({ queryKey: ["resumeItems"] });
// await queryClient.invalidateQueries({ queryKey: ["continueWatching"] });
// await queryClient.invalidateQueries({ queryKey: ["nextUp-all"] });
// await queryClient.invalidateQueries({
// queryKey: ["recentlyAddedInMovies"],
// });
// await queryClient.invalidateQueries({
// queryKey: ["recentlyAddedInTVShows"],
// });
// await queryClient.invalidateQueries({ queryKey: ["suggestions"] });
// await queryClient.invalidateQueries({
// queryKey: ["sf_promoted"],
// });
// await queryClient.invalidateQueries({
// queryKey: ["sf_carousel"],
// });
setLoading(false);
}, [queryClient, user?.Id]);
@@ -157,43 +164,34 @@ export default function index() {
if (!api || !user?.Id) return [];
const ss: Section[] = [
{
title: "Continue Watching",
queryKey: ["resumeItems", user.Id],
queryFn: async () =>
(
await getItemsApi(api).getResumeItems({
userId: user.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
{
title: "Next Up",
queryKey: ["nextUp-all", user?.Id],
queryFn: async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
})
).data.Items || [],
type: "ScrollingCollectionList",
orientation: "horizontal",
},
...(mediaListCollections?.map(
(ml) =>
({
title: ml.Name || "",
queryKey: ["mediaList", ml.Id],
queryFn: async () => ml,
type: "MediaListSection",
} as MediaListSection)
) || []),
// {
// title: "Continue Watching",
// queryKey: ["resumeItems", user.Id],
// queryFn: async () =>
// (
// await getItemsApi(api).getResumeItems({
// userId: user.Id,
// enableImageTypes: ["Primary", "Backdrop", "Thumb"],
// })
// ).data.Items || [],
// type: "ScrollingCollectionList",
// orientation: "horizontal",
// },
// {
// title: "Next Up",
// queryKey: ["nextUp-all", user?.Id],
// queryFn: async () =>
// (
// await getTvShowsApi(api).getNextUp({
// userId: user?.Id,
// fields: ["MediaSourceCount"],
// limit: 20,
// enableImageTypes: ["Primary", "Backdrop", "Thumb"],
// })
// ).data.Items || [],
// type: "ScrollingCollectionList",
// orientation: "horizontal",
// },
{
title: "Recently Added in Movies",
queryKey: ["recentlyAddedInMovies", user?.Id, movieCollectionId],
@@ -228,6 +226,15 @@ export default function index() {
).data || [],
type: "ScrollingCollectionList",
},
...(mediaListCollections?.map(
(ml) =>
({
title: ml.Name || "",
queryKey: ["mediaList", ml.Id],
queryFn: async () => ml,
type: "MediaListSection",
} as MediaListSection)
) || []),
{
title: "Suggested Movies",
queryKey: ["suggestedMovies", user?.Id],
@@ -317,7 +324,7 @@ export default function index() {
const insets = useSafeAreaInsets();
if (e1 || e2)
if (e1 || e2 || !api)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
@@ -341,39 +348,75 @@ export default function index() {
refreshControl={
<RefreshControl refreshing={loading} onRefresh={refetch} />
}
key={"home"}
contentContainerStyle={{
flexDirection: "column",
paddingLeft: insets.left,
paddingRight: insets.right,
paddingTop: 8,
paddingBottom: 8,
rowGap: 8,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View
style={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
className="flex flex-col pt-4 pb-24 gap-y-4"
>
<LargeMovieCarousel />
<LargeMovieCarousel />
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</View>
<ScrollingCollectionList
key="continueWatching"
title={"Continue Watching"}
queryKey={["continueWatching", user?.Id]}
queryFn={async () =>
(
await getItemsApi(api).getResumeItems({
userId: user?.Id,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
})
).data.Items || []
}
orientation={"horizontal"}
/>
<ScrollingCollectionList
key="nextUp"
title={"Next Up"}
queryKey={["nextUp-all", user?.Id]}
queryFn={async () =>
(
await getTvShowsApi(api).getNextUp({
userId: user?.Id,
fields: ["MediaSourceCount"],
limit: 20,
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
})
).data.Items || []
}
orientation={"horizontal"}
/>
{sections.map((section, index) => {
if (section.type === "ScrollingCollectionList") {
return (
<ScrollingCollectionList
key={index}
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
/>
);
} else if (section.type === "MediaListSection") {
return (
<MediaListSection
key={index}
queryKey={section.queryKey}
queryFn={section.queryFn}
/>
);
}
return null;
})}
</ScrollView>
);
}

View File

@@ -2,18 +2,20 @@ import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/ListItem";
import { SettingToggles } from "@/components/settings/SettingToggles";
import { useFiles } from "@/hooks/useFiles";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { clearLogs, readFromLog } from "@/utils/log";
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { ScrollView, View } from "react-native";
import { Alert, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
export default function settings() {
const { logout } = useJellyfin();
const { deleteAllFiles } = useFiles();
const { deleteAllFiles } = useDownload();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
@@ -26,6 +28,35 @@ export default function settings() {
const insets = useSafeAreaInsets();
const openQuickConnectAuthCodeInput = () => {
Alert.prompt(
"Quick connect",
"Enter the quick connect code",
async (text) => {
if (text) {
try {
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
code: text,
userId: user?.Id,
});
if (res.status === 200) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
Alert.alert("Success", "Quick connect authorized");
} else {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
} catch (e) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
Alert.alert("Error", "Invalid code");
}
}
}
);
};
return (
<ScrollView
contentContainerStyle={{
@@ -35,43 +66,68 @@ export default function settings() {
}}
>
<View className="p-4 flex flex-col gap-y-4">
{/* <Button
onPress={() => {
registerBackgroundFetchAsync();
}}
>
registerBackgroundFetchAsync
</Button> */}
<View>
<Text className="font-bold text-lg mb-2">Information</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
<ListItem title="User" subTitle={user?.Name} />
<ListItem title="Server" subTitle={api?.basePath} />
<ListItem title="Token" subTitle={api?.accessToken} />
</View>
</View>
<View>
<Text className="font-bold text-lg mb-2">Quick connect</Text>
<Button onPress={openQuickConnectAuthCodeInput} color="black">
Authorize
</Button>
</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
</Button>
<View>
<Text className="font-bold text-lg mb-2">Account and storage</Text>
<View className="flex flex-col space-y-2">
<Button color="black" onPress={logout}>
Log out
</Button>
<Button
color="red"
onPress={async () => {
try {
await deleteAllFiles();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
} catch (e) {
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Error
);
toast.error("Error deleting files");
}
}}
>
Delete all downloaded files
</Button>
<Button
color="red"
onPress={async () => {
await clearLogs();
Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
}}
>
Delete all logs
</Button>
</View>
</View>
<View>
<Text className="font-bold text-lg mb-2">Logs</Text>

View File

@@ -1,145 +0,0 @@
import { Text } from "@/components/common/Text";
import { List } from "@/components/List";
import { ListItem } from "@/components/ListItem";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { ActivityIndicator, Alert, ScrollView, View } from "react-native";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const queryClient = useQueryClient();
const name = useMemo(() => user?.Name || "", [user]);
const { data: activeGroups } = useQuery({
queryKey: ["syncplay", "activeGroups"],
queryFn: async () => {
if (!api) return [];
const res = await getSyncPlayApi(api).syncPlayGetGroups();
return res.data;
},
refetchInterval: 1000,
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: true,
});
const createGroupMutation = useMutation({
mutationFn: async (GroupName: string) => {
if (!api) return;
const res = await getSyncPlayApi(api).syncPlayCreateGroup({
newGroupRequestDto: {
GroupName,
},
});
if (res.status !== 204) {
Alert.alert("Error", "Failed to create group");
return false;
}
return true;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
},
});
const createGroup = () => {
Alert.prompt("Create Group", "Enter a name for the group", (text) => {
if (text) {
createGroupMutation.mutate(text);
}
});
};
const joinGroupMutation = useMutation({
mutationFn: async (groupId: string) => {
if (!api) return;
const res = await getSyncPlayApi(api).syncPlayJoinGroup({
joinGroupRequestDto: {
GroupId: groupId,
},
});
if (res.status !== 204) {
Alert.alert("Error", "Failed to join group");
return false;
}
return true;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
},
});
const leaveGroupMutation = useMutation({
mutationFn: async () => {
if (!api) return;
const res = await getSyncPlayApi(api).syncPlayLeaveGroup();
if (res.status !== 204) {
Alert.alert("Error", "Failed to exit group");
return false;
}
return true;
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["syncplay", "activeGroups"] });
},
});
return (
<ScrollView>
<View className="px-4 py-4">
<View>
<Text className="text-lg font-bold mb-4">Join group</Text>
{!activeGroups?.length && (
<Text className="text-neutral-500 mb-4">No active groups</Text>
)}
<List>
{activeGroups?.map((group) => (
<ListItem
key={group.GroupId}
title={group.GroupName}
onPress={async () => {
if (!group.GroupId) {
return;
}
if (group.Participants?.includes(name)) {
leaveGroupMutation.mutate();
} else {
joinGroupMutation.mutate(group.GroupId);
}
}}
iconAfter={
group.Participants?.includes(name) ? (
<Ionicons name="exit-outline" size={20} color="white" />
) : (
<Ionicons name="add" size={20} color="white" />
)
}
subTitle={group.Participants?.join(", ")}
/>
))}
<ListItem
onPress={() => createGroup()}
key={"create"}
title={"Create group"}
iconAfter={
createGroupMutation.isPending ? (
<ActivityIndicator size={20} color={"white"} />
) : (
<Ionicons name="add" size={20} color="white" />
)
}
/>
</List>
</View>
</View>
</ScrollView>
);
}

View File

@@ -34,8 +34,6 @@ import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { FlatList, View } from "react-native";
const MemoizedTouchableItemRouter = React.memo(TouchableItemRouter);
const page: React.FC = () => {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
@@ -169,7 +167,7 @@ const page: React.FC = () => {
const renderItem = useCallback(
({ item, index }: { item: BaseItemDto; index: number }) => (
<MemoizedTouchableItemRouter
<TouchableItemRouter
key={item.Id}
style={{
width: "100%",
@@ -193,7 +191,7 @@ const page: React.FC = () => {
{/* <MoviePoster item={item} /> */}
<ItemCardText item={item} />
</View>
</MemoizedTouchableItemRouter>
</TouchableItemRouter>
),
[orientation]
);

View File

@@ -1,13 +1,16 @@
import { ItemContent } from "@/components/ItemContent";
import { useLocalSearchParams } from "expo-router";
import React, { useMemo } from "react";
import { Stack, useLocalSearchParams } from "expo-router";
import React from "react";
const Page: React.FC = () => {
const { id } = useLocalSearchParams() as { id: string };
const memoizedContent = useMemo(() => <ItemContent id={id} />, [id]);
return memoizedContent;
return (
<>
<Stack.Screen options={{ autoHideHomeIndicator: true }} />
<ItemContent id={id} />
</>
);
};
export default React.memo(Page);
export default Page;

View File

@@ -10,6 +10,7 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React from "react";
import { useEffect, useMemo } from "react";
import { View } from "react-native";
@@ -20,8 +21,6 @@ const page: React.FC = () => {
seasonIndex: string;
};
console.log("seasonIndex", seasonIndex);
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);

View File

@@ -1,5 +1,9 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import {
useFocusEffect,
useLocalSearchParams,
useNavigation,
} from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
@@ -65,13 +69,11 @@ const Page = () => {
useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) {
console.log("getSortOrderPreference ~", sop, libraryId);
_setSortOrder([sop]);
} else {
_setSortOrder([SortOrderOption.Ascending]);
}
const obp = getSortByPreference(libraryId, sortByPreference);
console.log("getSortByPreference ~", obp, libraryId);
if (obp) {
_setSortBy([obp]);
} else {
@@ -83,7 +85,6 @@ const Page = () => {
(sortBy: SortByOption[]) => {
const sop = getSortByPreference(libraryId, sortByPreference);
if (sortBy[0] !== sop) {
console.log("setSortByPreference ~", sortBy[0], libraryId);
setSortByPreference({ ...sortByPreference, [libraryId]: sortBy[0] });
}
_setSortBy(sortBy);
@@ -95,7 +96,6 @@ const Page = () => {
(sortOrder: SortOrderOption[]) => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sortOrder[0] !== sop) {
console.log("setSortOrderPreference ~", sortOrder[0], libraryId);
setOderByPreference({
...sortOrderPreference,
[libraryId]: sortOrder[0],
@@ -128,6 +128,13 @@ const Page = () => {
staleTime: 60 * 1000,
});
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
title: library?.Name || "",
});
}, [library]);
const fetchItems = useCallback(
async ({
pageParam,

View File

@@ -195,6 +195,16 @@ export default function IndexLayout() {
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -19,6 +19,16 @@ export default function SearchLayout() {
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} />
))}
<Stack.Screen
name="collections/[collectionId]"
options={{
title: "",
headerShown: true,
headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false,
}}
/>
</Stack>
);
}

View File

@@ -8,6 +8,7 @@ import { Loader } from "@/components/Loader";
import AlbumCover from "@/components/posters/AlbumCover";
import MoviePoster from "@/components/posters/MoviePoster";
import SeriesPoster from "@/components/posters/SeriesPoster";
import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
@@ -225,9 +226,13 @@ export default function search() {
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
style={{
marginBottom: TAB_HEIGHT,
}}
>
<View className="flex flex-col pt-4 pb-32">
<View className="flex flex-col pt-2">
{Platform.OS === "android" && (
<View className="mb-4 px-4">
<Input
@@ -250,165 +255,125 @@ export default function search() {
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28"
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
renderItem={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.Id}`)}
className="flex flex-col w-28"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableOpacity>
)}
/>
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
renderItem={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/page?id=${item.Id}`)}
className="flex flex-col w-44"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
)}
/>
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableOpacity
key={item.Id}
className="flex flex-col w-28"
onPress={() => router.push(`/collections/${item.Id}`)}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableOpacity>
)}
/>
renderItem={(item) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
renderItem={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(data) => (
<HorizontalScroll
data={data}
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
renderItem={(item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
{loading ? (
@@ -445,7 +410,7 @@ export default function search() {
type Props = {
ids?: string[] | null;
renderItem: (data: BaseItemDto[]) => React.ReactNode;
renderItem: (item: BaseItemDto) => React.ReactNode;
header?: string;
};
@@ -483,8 +448,14 @@ const SearchItemWrapper: React.FC<Props> = ({ ids, renderItem, header }) => {
return (
<>
<Text className="font-bold text-2xl px-4 my-2">{header}</Text>
{renderItem(data)}
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<ScrollView
horizontal
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data.map((item) => renderItem(item))}
</ScrollView>
</>
);
};

14
app/(auth)/play-music.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { FullScreenMusicPlayer } from "@/components/FullScreenMusicPlayer";
import { StatusBar } from "expo-status-bar";
import { View, ViewProps } from "react-native";
interface Props extends ViewProps {}
export default function page() {
return (
<View className="">
<StatusBar hidden={false} />
<FullScreenMusicPlayer />
</View>
);
}

48
app/(auth)/play.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { FullScreenVideoPlayer } from "@/components/FullScreenVideoPlayer";
import { useSettings } from "@/utils/atoms/settings";
import * as NavigationBar from "expo-navigation-bar";
import * as ScreenOrientation from "expo-screen-orientation";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
import { Platform, View, ViewProps } from "react-native";
interface Props extends ViewProps {}
export default function page() {
const [settings] = useSettings();
useEffect(() => {
if (settings?.autoRotate) {
// Don't need to do anything
} else if (settings?.defaultVideoOrientation) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("hidden");
NavigationBar.setBehaviorAsync("overlay-swipe");
}
return () => {
if (settings?.autoRotate) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP
);
}
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
NavigationBar.setBehaviorAsync("inset-swipe");
}
};
}, [settings]);
return (
<View className="">
<StatusBar hidden />
<FullScreenVideoPlayer />
</View>
);
}

View File

@@ -1,27 +1,217 @@
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { DownloadProvider } from "@/providers/DownloadProvider";
import {
getOrSetDeviceId,
getTokenFromStoraage,
JellyfinProvider,
} from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaybackProvider } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Settings, useSettings } from "@/utils/atoms/settings";
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
checkForExistingDownloads,
completeHandler,
download,
} from "@kesha-antonov/react-native-background-downloader";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
import * as FileSystem from "expo-file-system";
import { useFonts } from "expo-font";
import { useKeepAwake } from "expo-keep-awake";
import { Stack, useRouter } from "expo-router";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { router, Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import * as TaskManager from "expo-task-manager";
import { Provider as JotaiProvider, useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { AppState } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "react-native-reanimated";
import * as Linking from "expo-linking";
import { orientationAtom } from "@/utils/atoms/orientation";
import { Toaster } from "sonner-native";
SplashScreen.preventAutoHideAsync();
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
function useNotificationObserver() {
useEffect(() => {
let isMounted = true;
function redirect(notification: Notifications.Notification) {
const url = notification.request.content.data?.url;
if (url) {
router.push(url);
}
}
Notifications.getLastNotificationResponseAsync().then((response) => {
if (!isMounted || !response?.notification) {
return;
}
redirect(response?.notification);
});
const subscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
redirect(response.notification);
}
);
return () => {
isMounted = false;
subscription.remove();
};
}, []);
}
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger");
const now = Date.now();
const settingsData = await AsyncStorage.getItem("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = await getTokenFromStoraage();
const deviceId = await getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
console.log({
token,
url,
deviceId,
});
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (let job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
console.log({
token,
deviceId,
baseDirectory,
url,
downloadUrl,
});
const tasks = await checkForExistingDownloads();
if (tasks.find((task) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
continue;
}
download({
id: job.id,
url: url + "download/" + job.id,
destination: `${baseDirectory}${job.item.Id}.mp4`,
headers: {
Authorization: token,
},
})
.begin(() => {
console.log("TaskManager ~ Download started: ", job.id);
})
.done(() => {
console.log("TaskManager ~ Download completed: ", job.id);
saveDownloadedItemInfo(job.item);
completeHandler(job.id);
cancelJobById({
authHeader: token,
id: job.id,
url: url,
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download completed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
})
.error((error) => {
console.log("TaskManager ~ Download error: ", job.id, error);
completeHandler(job.id);
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: "Download failed",
data: {
url: `/downloads`,
},
},
trigger: null,
});
});
}
}
console.log(`Auto download started: ${new Date(now).toISOString()}`);
// Be sure to return the successful result type!
return BackgroundFetch.BackgroundFetchResult.NewData;
});
const checkAndRequestPermissions = async () => {
try {
const hasAskedBefore = await AsyncStorage.getItem(
"hasAskedForNotificationPermission"
);
if (hasAskedBefore !== "true") {
const { status } = await Notifications.requestPermissionsAsync();
if (status === "granted") {
console.log("Notification permissions granted.");
} else {
console.log("Notification permissions denied.");
}
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
} else {
console.log("Already asked for notification permissions before.");
}
} catch (error) {
console.error("Error checking/requesting notification permissions:", error);
}
};
export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
@@ -49,12 +239,13 @@ function Layout() {
const [orientation, setOrientation] = useAtom(orientationAtom);
useKeepAwake();
useNotificationObserver();
const queryClientRef = useRef<QueryClient>(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
@@ -64,6 +255,10 @@ function Layout() {
})
);
useEffect(() => {
checkAndRequestPermissions();
}, []);
useEffect(() => {
if (settings?.autoRotate === true)
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
@@ -73,10 +268,28 @@ function Layout() {
);
}, [settings]);
const appState = useRef(AppState.currentState);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
checkForExistingDownloads();
}
});
checkForExistingDownloads();
return () => {
subscription.remove();
};
}, []);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
console.log(event.orientationInfo.orientation);
setOrientation(event.orientationInfo.orientation);
}
);
@@ -100,34 +313,90 @@ function Layout() {
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClientRef.current}>
<JobQueueProvider>
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack initialRouteName="/home">
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
<DownloadProvider>
<ActionSheetProvider>
<BottomSheetModalProvider>
<JellyfinProvider>
<PlaybackProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack
initialRouteName="/home"
screenOptions={{
autoHideHomeIndicator: true,
}}
>
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "",
}}
/>
<Stack.Screen
name="(auth)/play"
options={{
headerShown: false,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="(auth)/play-music"
options={{
headerShown: false,
title: "",
animation: "fade",
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</PlaybackProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>
</ThemeProvider>
</PlaybackProvider>
</JellyfinProvider>
</BottomSheetModalProvider>
</ActionSheetProvider>
</DownloadProvider>
</JobQueueProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}
async function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {
items[existingItemIndex] = item;
} else {
items.push(item);
}
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
} catch (error) {
console.error("Failed to save downloaded item information:", error);
}
}

View File

@@ -3,6 +3,8 @@ import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons";
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import React, { useEffect, useState } from "react";
@@ -33,6 +35,7 @@ const Login: React.FC = () => {
} = params as { apiUrl: string; username: string; password: string };
const [serverURL, setServerURL] = useState<string>(_apiUrl);
const [serverName, setServerName] = useState<string>("");
const [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{
username: string;
@@ -44,6 +47,8 @@ const Login: React.FC = () => {
useEffect(() => {
(async () => {
// we might re-use the checkUrl function here to check the url as well
// however, I don't think it should be necessary for now
if (_apiUrl) {
setServer({
address: _apiUrl,
@@ -79,12 +84,93 @@ const Login: React.FC = () => {
}
};
const handleConnect = (url: string) => {
if (!url.startsWith("http")) {
Alert.alert("Error", "URL needs to start with http or https.");
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
/**
* Checks the availability and validity of a Jellyfin server URL.
*
* This function attempts to connect to a Jellyfin server using the provided URL.
* It tries both HTTPS and HTTP protocols, with a timeout to handle long 404 responses.
*
* @param {string} url - The base URL of the Jellyfin server to check.
* @returns {Promise<string | undefined>} A Promise that resolves to:
* - The full URL (including protocol) if a valid Jellyfin server is found.
* - undefined if no valid server is found at the given URL.
*
* Side effects:
* - Sets loadingServerCheck state to true at the beginning and false at the end.
* - Logs errors and timeout information to the console.
*/
async function checkUrl(url: string) {
url = url.endsWith("/") ? url.slice(0, -1) : url;
setLoadingServerCheck(true);
const protocols = ["https://", "http://"];
const timeout = 2000; // 2 seconds timeout for long 404 responses
try {
for (const protocol of protocols) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(`${protocol}${url}/System/Info/Public`, {
mode: "cors",
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const data = (await response.json()) as PublicSystemInfo;
setServerName(data.ServerName || "");
return `${protocol}${url}`;
}
} catch (e) {
const error = e as Error;
if (error.name === "AbortError") {
console.log(`Request to ${protocol}${url} timed out`);
} else {
console.error(`Error checking ${protocol}${url}:`, error);
}
}
}
return undefined;
} finally {
setLoadingServerCheck(false);
}
}
/**
* Handles the connection attempt to a Jellyfin server.
*
* This function trims the input URL, checks its validity using the `checkUrl` function,
* and sets the server address if a valid connection is established.
*
* @param {string} url - The URL of the Jellyfin server to connect to.
*
* @returns {Promise<void>}
*
* Side effects:
* - Calls `checkUrl` to validate the server URL.
* - Shows an alert if the connection fails.
* - Sets the server address using `setServer` if the connection is successful.
*
*/
const handleConnect = async (url: string) => {
url = url.trim();
const result = await checkUrl(
url.startsWith("http") ? new URL(url).host : url
);
if (result === undefined) {
Alert.alert(
"Connection failed",
"Could not connect to the server. Please check the URL and your network connection."
);
return;
}
setServer({ address: url.trim() });
setServer({ address: result });
};
const handleQuickConnect = async () => {
@@ -113,7 +199,9 @@ const Login: React.FC = () => {
<View></View>
<View>
<View className="mb-4">
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
<Text className="text-3xl font-bold mb-1">
{serverName || "Streamyfin"}
</Text>
<Text className="text-neutral-500 mb-2">
Server: {api.basePath}
</Text>
@@ -121,7 +209,6 @@ const Login: React.FC = () => {
color="black"
onPress={() => {
removeServer();
setServerURL("");
}}
justify="between"
iconLeft={
@@ -138,9 +225,6 @@ const Login: React.FC = () => {
<View className="flex flex-col space-y-2">
<Text className="text-2xl font-bold">Log in</Text>
<Text className="text-neutral-500">
Log in to any user account
</Text>
<Input
placeholder="Username"
onChangeText={(text) =>
@@ -218,11 +302,13 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Text className="opacity-30">
Server URL requires http or https
</Text>
</View>
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
<Button
loading={loadingServerCheck}
disabled={loadingServerCheck}
onPress={async () => await handleConnect(serverURL)}
className="mb-2"
>
Connect
</Button>
</View>

BIN
bun.lockb

Binary file not shown.

View File

@@ -45,7 +45,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
}
const index = source.DefaultAudioStreamIndex;
if (index !== undefined && index !== null) {
console.log("DefaultAudioStreamIndex", index);
onChange(index);
return;
}

View File

@@ -5,17 +5,18 @@ import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { View } from "react-native";
import { WatchedIndicator } from "./WatchedIndicator";
import React from "react";
type ContinueWatchingPosterProps = {
item: BaseItemDto;
width?: number;
useEpisodePoster?: boolean;
size?: "small" | "normal";
};
const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
item,
width = 176,
useEpisodePoster = false,
size = "normal",
}) => {
const [api] = useAtom(apiAtom);
@@ -47,20 +48,15 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
if (!url)
return (
<View
className="aspect-video border border-neutral-800"
style={{
width,
}}
></View>
<View className="aspect-video border border-neutral-800 w-44"></View>
);
return (
<View
style={{
width,
}}
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
className={`
relative w-44 aspect-video rounded-lg overflow-hidden border border-neutral-800
${size === "small" ? "w-32" : "w-44"}
`}
>
<Image
key={item.Id}
@@ -76,10 +72,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
{progress > 0 && (
<>
<View
style={{
width: `100%`,
}}
className={`absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
></View>
<View
style={{

View File

@@ -1,313 +0,0 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import { Ionicons } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import { useRouter, useSegments } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useMemo } from "react";
import { Alert, Platform, TouchableOpacity, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Video from "react-native-video";
import { Text } from "./common/Text";
import { Loader } from "./Loader";
export const CurrentlyPlayingBar: React.FC = () => {
const segments = useSegments();
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setVolume,
setIsPlaying,
isPlaying,
videoRef,
presentFullscreenPlayer,
onProgress,
onBuffer,
} = usePlayback();
const [api] = useAtom(apiAtom);
const aBottom = useSharedValue(0);
const aPadding = useSharedValue(0);
const aHeight = useSharedValue(100);
const router = useRouter();
const animatedOuterStyle = useAnimatedStyle(() => {
return {
bottom: withTiming(aBottom.value, { duration: 500 }),
height: withTiming(aHeight.value, { duration: 500 }),
padding: withTiming(aPadding.value, { duration: 500 }),
};
});
const aPaddingBottom = useSharedValue(30);
const aPaddingInner = useSharedValue(12);
const aBorderRadiusBottom = useSharedValue(12);
const animatedInnerStyle = useAnimatedStyle(() => {
return {
padding: withTiming(aPaddingInner.value, { duration: 500 }),
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
duration: 500,
}),
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
duration: 500,
}),
};
});
const from = useMemo(() => segments[2], [segments]);
useEffect(() => {
if (segments.find((s) => s.includes("tabs"))) {
// Tab screen - i.e. home
aBottom.value = Platform.OS === "ios" ? 78 : 50;
aHeight.value = 80;
aPadding.value = 8;
aPaddingBottom.value = 8;
aPaddingInner.value = 8;
} else {
// Inside a normal screen
aBottom.value = Platform.OS === "ios" ? 0 : 0;
aHeight.value = Platform.OS === "ios" ? 110 : 80;
aPadding.value = Platform.OS === "ios" ? 0 : 8;
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
}
}, [segments]);
const startPosition = useMemo(
() =>
currentlyPlaying?.item?.UserData?.PlaybackPositionTicks
? Math.round(
currentlyPlaying?.item.UserData.PlaybackPositionTicks / 10000
)
: 0,
[currentlyPlaying?.item]
);
const poster = useMemo(() => {
if (currentlyPlaying?.item.Type === "Audio")
return `${api?.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`;
else
return getBackdropUrl({
api,
item: currentlyPlaying?.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item.Id, api]);
const videoSource = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return null;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist
? currentlyPlaying.item?.AlbumArtist
: undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview
? currentlyPlaying.item?.Overview
: undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album
? currentlyPlaying.item?.Album
: undefined,
},
};
}, [currentlyPlaying, startPosition, api, poster]);
if (!api || !currentlyPlaying) return null;
return (
<Animated.View
style={[animatedOuterStyle]}
className="absolute left-0 w-screen"
>
<BlurView
intensity={Platform.OS === "android" ? 60 : 100}
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
className={`h-full w-full rounded-xl overflow-hidden ${
Platform.OS === "android" && "bg-black"
}`}
>
<Animated.View
style={[
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
animatedInnerStyle,
]}
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
>
<View className="flex flex-row items-center space-x-4 shrink">
<TouchableOpacity
onPress={() => {
videoRef.current?.presentFullscreenPlayer();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
${
currentlyPlaying.item?.Type === "Audio"
? "aspect-square"
: "aspect-video"
}
`}
>
{videoSource && (
<Video
ref={videoRef}
allowsExternalPlayback
style={{ width: "100%", height: "100%" }}
playWhenInactive={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
controls={false}
pictureInPicture={true}
poster={
poster && currentlyPlaying.item?.Type === "Audio"
? poster
: undefined
}
debug={{
enable: true,
thread: true,
}}
onIdle={() => {
console.log("IDLE");
}}
fullscreenAutorotate={true}
onReadyForDisplay={() => {
console.log("READY FOR DISPLAY");
}}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
onBuffer={(e) => onBuffer(e.isBuffering)}
source={videoSource}
onRestoreUserInterfaceForPictureInPictureStop={() => {
setTimeout(() => {
presentFullscreenPlayer();
}, 300);
}}
onPlaybackStateChanged={(e) => {
if (e.isPlaying === true) {
playVideo(false);
} else if (e.isPlaying === false) {
pauseVideo(false);
}
}}
onVolumeChange={(e) => {
setVolume(e.volume);
}}
progressUpdateInterval={4000}
onError={(e) => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e)
);
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
// setCurrentlyPlaying(null);
}}
renderLoader={
currentlyPlaying.item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<Loader />
</View>
)
}
/>
)}
</TouchableOpacity>
<View className="shrink text-xs">
<TouchableOpacity
onPress={() => {
if (currentlyPlaying.item?.Type === "Audio") {
router.push(
// @ts-ignore
`/(auth)/(tabs)/${from}/albums/${currentlyPlaying.item.AlbumId}`
);
} else {
router.push(
// @ts-ignore
`/(auth)/(tabs)/${from}/items/page?id=${currentlyPlaying.item?.Id}`
);
}
}}
>
<Text>{currentlyPlaying.item?.Name}</Text>
</TouchableOpacity>
{currentlyPlaying.item?.Type === "Episode" && (
<TouchableOpacity
onPress={() => {
router.push(
// @ts-ignore
`/(auth)/(tabs)/${from}/series/${currentlyPlaying.item.SeriesId}`
);
}}
className="text-xs opacity-50"
>
<Text>{currentlyPlaying.item.SeriesName}</Text>
</TouchableOpacity>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<View>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
</View>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<TouchableOpacity
onPress={() => {
router.push(`/albums/${currentlyPlaying.item?.AlbumId}`);
}}
>
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
</TouchableOpacity>
)}
</View>
</View>
<View className="flex flex-row items-center space-x-2">
<TouchableOpacity
onPress={() => {
if (isPlaying) pauseVideo();
else playVideo();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
{isPlaying ? (
<Ionicons name="pause" size={24} color="white" />
) : (
<Ionicons name="play" size={24} color="white" />
)}
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
stopPlayback();
}}
className="aspect-square rounded flex flex-col items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
</Animated.View>
</BlurView>
</Animated.View>
);
};

View File

@@ -1,6 +1,6 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
@@ -17,8 +17,6 @@ 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 { useCallback, useMemo, useRef, useState } from "react";
@@ -31,6 +29,7 @@ import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import ProgressCircle from "./ProgressCircle";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
import { toast } from "sonner-native";
interface DownloadProps extends ViewProps {
item: BaseItemDto;
@@ -39,10 +38,10 @@ interface DownloadProps extends ViewProps {
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 { processes, startBackgroundDownload } = useDownload();
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(item);
const [selectedMediaSource, setSelectedMediaSource] =
useState<MediaSourceInfo | null>(null);
@@ -67,9 +66,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
bottomSheetModalRef.current?.present();
}, []);
const handleSheetChanges = useCallback((index: number) => {
console.log("handleSheetChanges", index);
}, []);
const handleSheetChanges = useCallback((index: number) => {}, []);
const closeModal = useCallback(() => {
bottomSheetModalRef.current?.dismiss();
@@ -115,6 +112,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
);
let url: string | undefined = undefined;
let fileExtension: string | undefined | null = "mp4";
const mediaSource: MediaSourceInfo = response.data.MediaSources.find(
(source: MediaSourceInfo) => source.Id === selectedMediaSource?.Id
@@ -126,7 +124,6 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
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&mediaSourceId=${mediaSource.Id}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
@@ -149,40 +146,40 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
}/universal?${searchParams.toString()}`;
}
} else if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
fileExtension = mediaSource.TranscodingContainer;
}
if (!url) throw new Error("No url");
if (!fileExtension) throw new Error("No file extension");
return await startRemuxing(url);
if (settings?.downloadMethod === "optimized") {
return await startBackgroundDownload(url, item, fileExtension);
} else {
return await startRemuxing(url);
}
}, [
api,
item,
startRemuxing,
startBackgroundDownload,
user?.Id,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
maxBitrate,
settings?.downloadMethod,
]);
/**
* Check if item is downloaded
*/
const { data: downloaded, isFetching } = useQuery({
queryKey: ["downloaded", item.Id],
queryFn: async () => {
if (!item.Id) return false;
const { downloadedFiles } = useDownload();
const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]"
);
const isDownloaded = useMemo(() => {
if (!downloadedFiles) return false;
return data.some((d) => d.Id === item.Id);
},
enabled: !!item.Id,
});
return downloadedFiles.some((file) => file.Id === item.Id);
}, [downloadedFiles, item.Id]);
const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => (
@@ -195,14 +192,18 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
[]
);
const process = useMemo(() => {
if (!processes) return null;
return processes.find((process) => process?.item?.Id === item.Id);
}, [processes, item.Id]);
return (
<View
className="bg-neutral-800/80 rounded-full h-10 w-10 flex items-center justify-center"
{...props}
>
{isFetching ? (
<Loader />
) : process && process?.item.Id === item.Id ? (
{process && process?.item.Id === item.Id ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
@@ -230,7 +231,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
>
<Ionicons name="hourglass" size={24} color="white" />
</TouchableOpacity>
) : downloaded ? (
) : isDownloaded ? (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
@@ -290,25 +291,36 @@ export const DownloadItem: React.FC<DownloadProps> = ({ item, ...props }) => {
className="mt-auto"
onPress={() => {
if (userCanDownload === true) {
if (!item.Id) {
throw new Error("No item id");
}
closeModal();
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
await initiateDownload();
},
item,
});
if (settings?.downloadMethod === "remux") {
queueActions.enqueue(queue, setQueue, {
id: item.Id,
execute: async () => {
await initiateDownload();
},
item,
});
} else {
initiateDownload();
}
} else {
Alert.alert(
"Disabled",
"This user is not allowed to download files."
);
toast.error("You are not allowed to download files.");
}
}}
color="purple"
>
Download
</Button>
<View className="opacity-70 text-center w-full flex items-center">
{settings?.downloadMethod === "optimized" ? (
<Text className="text-xs">Using optimized server</Text>
) : (
<Text className="text-xs">Using default method</Text>
)}
</View>
</View>
</BottomSheetView>
</BottomSheetModal>

View File

@@ -0,0 +1,544 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData } from "react-native-video";
import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
export const FullScreenMusicPlayer: React.FC = () => {
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
setIsBuffering,
} = usePlayback();
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const progress = useSharedValue(0);
const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const subscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
return () => subscription?.remove();
});
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
const backAction = () => {
if (currentlyPlaying) {
Alert.alert("Hold on!", "Are you sure you want to exit?", [
{
text: "Cancel",
onPress: () => null,
style: "cancel",
},
{
text: "Yes",
onPress: () => {
stopPlayback();
router.back();
},
},
]);
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}, [currentlyPlaying, stopPlayback, router]);
const poster = useMemo(() => {
if (!currentlyPlaying?.item || !api) return "";
return currentlyPlaying.item.Type === "Audio"
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: currentlyPlaying.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item, api]);
const videoSource = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return null;
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview ?? undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album ?? undefined,
},
};
}, [currentlyPlaying, api, poster]);
useEffect(() => {
if (currentlyPlaying) {
progress.value =
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0;
setShowControls(true);
playVideo();
}
}, [currentlyPlaying]);
const toggleControls = () => setShowControls(!showControls);
const handleVideoProgress = useCallback(
(data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBufferingState(data.playableDuration === 0);
setIsBuffering(data.playableDuration === 0);
onProgress(data);
},
[onProgress, setIsBuffering, isSeeking]
);
const handleVideoError = useCallback(
(e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handlePlayPause = useCallback(() => {
if (isPlaying) pauseVideo();
else playVideo();
}, [isPlaying, pauseVideo, playVideo]);
const handleSliderComplete = (value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
};
const handleSliderChange = (value: number) => {};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [previousItem, from, stopPlayback, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [nextItem, from, stopPlayback, router]);
if (!currentlyPlaying) return null;
return (
<View
style={{
width: dimensions.window.width,
height: dimensions.window.height,
backgroundColor: "black",
}}
>
<Pressable
onPress={toggleControls}
style={[
{
position: "absolute",
top: 0,
bottom: 0,
left: insets.left,
right: insets.right,
width: dimensions.window.width - (insets.left + insets.right),
},
]}
>
{videoSource && (
<>
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={"contain"}
onProgress={handleVideoProgress}
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
onError={handleVideoError}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
/>
</>
)}
</Pressable>
<View pointerEvents="none" className="p-4">
<Image
source={poster ? { uri: poster } : undefined}
style={{
width: "100%",
height: "100%",
resizeMode: "contain",
}}
/>
</View>
{(showControls || isBuffering) && (
<View
pointerEvents="none"
style={[
{
top: 0,
left: 0,
position: "absolute",
width: dimensions.window.width,
height: dimensions.window.height,
},
]}
className=" bg-black/50 z-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 70,
right: insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
)}
{showSkipCreditButton && (
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 70,
right: insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
)}
{showControls && (
<>
<View
style={[
{
position: "absolute",
top: insets.top,
right: insets.right + 16,
height: 70,
zIndex: 10,
},
]}
className="flex flex-row items-center space-x-2 z-10"
>
<TouchableOpacity
onPress={() => {
stopPlayback();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 8,
left: insets.left + 16,
width:
dimensions.window.width - insets.left - insets.right - 32,
},
]}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
{currentlyPlaying.item?.Type === "Episode" && (
<Text className="opacity-50">
{currentlyPlaying.item.SeriesName}
</Text>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
)}
</View>
<View
className={`flex ${"flex-col-reverse py-4 px-4 rounded-2xl"}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handlePlayPause}>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View
className={`flex flex-col w-full shrink
${""}
`}
>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
sliderHeight={10}
progress={progress}
thumbWidth={0}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</>
)}
</View>
);
};

View File

@@ -0,0 +1,625 @@
import { useAdjacentEpisodes } from "@/hooks/useAdjacentEpisodes";
import { useCreditSkipper } from "@/hooks/useCreditSkipper";
import { useIntroSkipper } from "@/hooks/useIntroSkipper";
import { useTrickplay } from "@/hooks/useTrickplay";
import { apiAtom } from "@/providers/JellyfinProvider";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
import orientationToOrientationLock from "@/utils/OrientationLockConverter";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { formatTimeString, ticksToSeconds } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useRouter, useSegments } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
BackHandler,
Dimensions,
Pressable,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, { OnProgressData } from "react-native-video";
import { Text } from "./common/Text";
import { itemRouter } from "./common/TouchableItemRouter";
import { Loader } from "./Loader";
const windowDimensions = Dimensions.get("window");
const screenDimensions = Dimensions.get("screen");
export const FullScreenVideoPlayer: React.FC = () => {
const {
currentlyPlaying,
pauseVideo,
playVideo,
stopPlayback,
setIsPlaying,
isPlaying,
videoRef,
onProgress,
setIsBuffering,
} = usePlayback();
const [settings] = useSettings();
const [api] = useAtom(apiAtom);
const router = useRouter();
const segments = useSegments();
const insets = useSafeAreaInsets();
const { previousItem, nextItem } = useAdjacentEpisodes({ currentlyPlaying });
const { trickPlayUrl, calculateTrickplayUrl, trickplayInfo } =
useTrickplay(currentlyPlaying);
const [showControls, setShowControls] = useState(true);
const [isBuffering, setIsBufferingState] = useState(true);
const [ignoreSafeArea, setIgnoreSafeArea] = useState(false);
const [orientation, setOrientation] = useState(
ScreenOrientation.OrientationLock.UNKNOWN
);
// Seconds
const [currentTime, setCurrentTime] = useState(0);
const [remainingTime, setRemainingTime] = useState(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const progress = useSharedValue(0);
const min = useSharedValue(0);
const max = useSharedValue(currentlyPlaying?.item.RunTimeTicks || 0);
const [dimensions, setDimensions] = useState({
window: windowDimensions,
screen: screenDimensions,
});
useEffect(() => {
const dimensionsSubscription = Dimensions.addEventListener(
"change",
({ window, screen }) => {
setDimensions({ window, screen });
}
);
const orientationSubscription =
ScreenOrientation.addOrientationChangeListener((event) => {
setOrientation(
orientationToOrientationLock(event.orientationInfo.orientation)
);
});
ScreenOrientation.getOrientationAsync().then((orientation) => {
setOrientation(orientationToOrientationLock(orientation));
});
return () => {
dimensionsSubscription.remove();
orientationSubscription.remove();
};
}, []);
const from = useMemo(() => segments[2], [segments]);
const updateTimes = useCallback(
(currentProgress: number, maxValue: number) => {
const current = ticksToSeconds(currentProgress);
const remaining = ticksToSeconds(maxValue - current);
setCurrentTime(current);
setRemainingTime(remaining);
},
[]
);
const { showSkipButton, skipIntro } = useIntroSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
const { showSkipCreditButton, skipCredit } = useCreditSkipper(
currentlyPlaying?.item.Id,
currentTime,
videoRef
);
useAnimatedReaction(
() => ({
progress: progress.value,
max: max.value,
isSeeking: isSeeking.value,
}),
(result) => {
if (result.isSeeking === false) {
runOnJS(updateTimes)(result.progress, result.max);
}
},
[updateTimes]
);
useEffect(() => {
const backAction = () => {
if (currentlyPlaying) {
Alert.alert("Hold on!", "Are you sure you want to exit?", [
{
text: "Cancel",
onPress: () => null,
style: "cancel",
},
{
text: "Yes",
onPress: () => {
stopPlayback();
router.back();
},
},
]);
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener(
"hardwareBackPress",
backAction
);
return () => backHandler.remove();
}, [currentlyPlaying, stopPlayback, router]);
const isLandscape = useMemo(() => {
return orientation === ScreenOrientation.OrientationLock.LANDSCAPE_LEFT ||
orientation === ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
? true
: false;
}, [orientation]);
const poster = useMemo(() => {
if (!currentlyPlaying?.item || !api) return "";
return currentlyPlaying.item.Type === "Audio"
? `${api.basePath}/Items/${currentlyPlaying.item.AlbumId}/Images/Primary?tag=${currentlyPlaying.item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: currentlyPlaying.item,
quality: 70,
width: 200,
});
}, [currentlyPlaying?.item, api]);
const videoSource = useMemo(() => {
if (!api || !currentlyPlaying || !poster) return null;
const startPosition = currentlyPlaying.item?.UserData?.PlaybackPositionTicks
? Math.round(currentlyPlaying.item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: currentlyPlaying.url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: currentlyPlaying.item?.AlbumArtist ?? undefined,
title: currentlyPlaying.item?.Name || "Unknown",
description: currentlyPlaying.item?.Overview ?? undefined,
imageUri: poster,
subtitle: currentlyPlaying.item?.Album ?? undefined,
},
};
}, [currentlyPlaying, api, poster]);
useEffect(() => {
if (currentlyPlaying) {
progress.value =
currentlyPlaying.item?.UserData?.PlaybackPositionTicks || 0;
max.value = currentlyPlaying.item.RunTimeTicks || 0;
setShowControls(true);
playVideo();
}
}, [currentlyPlaying]);
const toggleControls = () => setShowControls(!showControls);
const handleVideoProgress = useCallback(
(data: OnProgressData) => {
if (isSeeking.value === true) return;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBufferingState(data.playableDuration === 0);
setIsBuffering(data.playableDuration === 0);
onProgress(data);
},
[onProgress, setIsBuffering, isSeeking]
);
const handleVideoError = useCallback(
(e: any) => {
console.log(e);
writeToLog("ERROR", "Video playback error: " + JSON.stringify(e));
Alert.alert("Error", "Cannot play this video file.");
setIsPlaying(false);
},
[setIsPlaying]
);
const handlePlayPause = useCallback(() => {
if (isPlaying) pauseVideo();
else playVideo();
}, [isPlaying, pauseVideo, playVideo]);
const handleSliderComplete = (value: number) => {
progress.value = value;
isSeeking.value = false;
videoRef.current?.seek(value / 10000000);
};
const handleSliderChange = (value: number) => {
calculateTrickplayUrl(value);
};
const handleSliderStart = useCallback(() => {
if (showControls === false) return;
isSeeking.value = true;
}, []);
const handleSkipBackward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr - settings.rewindSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video backwards", error);
}
}, [settings]);
const handleSkipForward = useCallback(async () => {
if (!settings) return;
try {
const curr = await videoRef.current?.getCurrentPosition();
if (curr !== undefined) {
videoRef.current?.seek(Math.max(0, curr + settings.forwardSkipTime));
}
} catch (error) {
writeToLog("ERROR", "Error seeking video forwards", error);
}
}, [settings]);
const handleGoToPreviousItem = useCallback(() => {
if (!previousItem || !from) return;
const url = itemRouter(previousItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [previousItem, from, stopPlayback, router]);
const handleGoToNextItem = useCallback(() => {
if (!nextItem || !from) return;
const url = itemRouter(nextItem, from);
stopPlayback();
// @ts-ignore
router.push(url);
}, [nextItem, from, stopPlayback, router]);
const toggleIgnoreSafeArea = useCallback(() => {
setIgnoreSafeArea((prev) => !prev);
}, []);
if (!currentlyPlaying) return null;
return (
<View
style={{
width: dimensions.window.width,
height: dimensions.window.height,
backgroundColor: "black",
}}
>
<Pressable
onPress={toggleControls}
style={[
{
position: "absolute",
top: 0,
bottom: 0,
left: ignoreSafeArea ? 0 : insets.left,
right: ignoreSafeArea ? 0 : insets.right,
width: ignoreSafeArea
? dimensions.window.width
: dimensions.window.width - (insets.left + insets.right),
},
]}
>
{videoSource && (
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeArea ? "cover" : "contain"}
onProgress={handleVideoProgress}
onLoad={(data) => (max.value = secondsToTicks(data.duration))}
onError={handleVideoError}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
/>
)}
</Pressable>
{(showControls || isBuffering) && (
<View
pointerEvents="none"
style={[
{
top: 0,
left: 0,
position: "absolute",
width: dimensions.window.width,
height: dimensions.window.height,
},
]}
className=" bg-black/50 z-0"
></View>
)}
{isBuffering && (
<View
pointerEvents="none"
className="fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center"
>
<Loader />
</View>
)}
{showSkipButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipIntro}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Intro</Text>
</TouchableOpacity>
</View>
)}
{showSkipCreditButton && (
<View
style={[
{
position: "absolute",
bottom: isLandscape ? insets.bottom + 26 : insets.bottom + 70,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
},
]}
className="z-10"
>
<TouchableOpacity
onPress={skipCredit}
className="bg-purple-600 rounded-full px-2.5 py-2 font-semibold"
>
<Text className="text-white">Skip Credits</Text>
</TouchableOpacity>
</View>
)}
{showControls && (
<>
<View
style={[
{
position: "absolute",
top: insets.top,
right: isLandscape ? insets.right + 32 : insets.right + 16,
height: 70,
zIndex: 10,
},
]}
className="flex flex-row items-center space-x-2 z-10"
>
<TouchableOpacity
onPress={toggleIgnoreSafeArea}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons
name={ignoreSafeArea ? "contract-outline" : "expand"}
size={24}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
stopPlayback();
router.back();
}}
className="aspect-square flex flex-col bg-neutral-800 rounded-xl items-center justify-center p-2"
>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
<View
style={[
{
position: "absolute",
bottom: insets.bottom + 8,
left: isLandscape ? insets.left + 32 : insets.left + 16,
width: isLandscape
? dimensions.window.width - insets.left - insets.right - 64
: dimensions.window.width - insets.left - insets.right - 32,
},
]}
>
<View className="shrink flex flex-col justify-center h-full mb-2">
<Text className="font-bold">{currentlyPlaying.item?.Name}</Text>
{currentlyPlaying.item?.Type === "Episode" && (
<Text className="opacity-50">
{currentlyPlaying.item.SeriesName}
</Text>
)}
{currentlyPlaying.item?.Type === "Movie" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.ProductionYear}
</Text>
)}
{currentlyPlaying.item?.Type === "Audio" && (
<Text className="text-xs opacity-50">
{currentlyPlaying.item?.Album}
</Text>
)}
</View>
<View
className={`flex ${
isLandscape
? "flex-row space-x-6 py-2 px-4 rounded-full"
: "flex-col-reverse py-4 px-4 rounded-2xl"
}
items-center bg-neutral-800`}
>
<View className="flex flex-row items-center space-x-4">
<TouchableOpacity
style={{
opacity: !previousItem ? 0.5 : 1,
}}
onPress={handleGoToPreviousItem}
>
<Ionicons name="play-skip-back" size={24} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipBackward}>
<Ionicons
name="refresh-outline"
size={26}
color="white"
style={{
transform: [{ scaleY: -1 }, { rotate: "180deg" }],
}}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handlePlayPause}>
<Ionicons
name={isPlaying ? "pause" : "play"}
size={30}
color="white"
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleSkipForward}>
<Ionicons name="refresh-outline" size={26} color="white" />
</TouchableOpacity>
<TouchableOpacity
style={{
opacity: !nextItem ? 0.5 : 1,
}}
onPress={handleGoToNextItem}
>
<Ionicons name="play-skip-forward" size={24} color="white" />
</TouchableOpacity>
</View>
<View
className={`flex flex-col w-full shrink
${""}
`}
>
<Slider
theme={{
maximumTrackTintColor: "rgba(255,255,255,0.2)",
minimumTrackTintColor: "#fff",
cacheTrackTintColor: "rgba(255,255,255,0.3)",
bubbleBackgroundColor: "#fff",
bubbleTextColor: "#000",
heartbeatColor: "#999",
}}
cache={cacheProgress}
onSlidingStart={handleSliderStart}
onSlidingComplete={handleSliderComplete}
onValueChange={handleSliderChange}
containerStyle={{
borderRadius: 100,
}}
renderBubble={() => {
if (!trickPlayUrl || !trickplayInfo) {
return null;
}
const { x, y, url } = trickPlayUrl;
const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!;
return (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
width: tileWidth,
height: tileHeight,
marginLeft: -tileWidth / 4,
marginTop: -tileHeight / 4 - 60,
zIndex: 10,
}}
className=" bg-neutral-800 overflow-hidden"
>
<Image
cachePolicy={"memory-disk"}
style={{
width: 150 * trickplayInfo?.data.TileWidth!,
height:
(150 / trickplayInfo.aspectRatio!) *
trickplayInfo?.data.TileHeight!,
transform: [
{ translateX: -x * tileWidth },
{ translateY: -y * tileHeight },
],
}}
source={{ uri: url }}
contentFit="cover"
/>
</View>
);
}}
sliderHeight={10}
thumbWidth={0}
progress={progress}
minimumValue={min}
maximumValue={max}
/>
<View className="flex flex-row items-center justify-between mt-0.5">
<Text className="text-[12px] text-neutral-400">
{formatTimeString(currentTime)}
</Text>
<Text className="text-[12px] text-neutral-400">
-{formatTimeString(remainingTime)}
</Text>
</View>
</View>
</View>
</View>
</>
)}
</View>
);
};

25
components/GenreTags.tsx Normal file
View File

@@ -0,0 +1,25 @@
// GenreTags.tsx
import React from "react";
import { View } from "react-native";
import { Text } from "./common/Text";
interface GenreTagsProps {
genres?: string[];
}
export const GenreTags: React.FC<GenreTagsProps> = ({ genres }) => {
if (!genres || genres.length === 0) return null;
return (
<View className="flex flex-row flex-wrap mt-2">
{genres.map((genre) => (
<View
key={genre}
className="bg-neutral-800 rounded-full px-2 py-1 mr-1"
>
<Text className="text-xs">{genre}</Text>
</View>
))}
</View>
);
};

View File

@@ -10,7 +10,7 @@ type ItemCardProps = {
export const ItemCardText: React.FC<ItemCardProps> = ({ item }) => {
return (
<View className="mt-2 flex flex-col h-12">
<View className="mt-2 flex flex-col">
{item.Type === "Episode" ? (
<>
<Text numberOfLines={2} className="">

View File

@@ -26,7 +26,7 @@ import { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useNavigation } from "expo-router";
import { Stack, useNavigation } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef, useState } from "react";
@@ -43,6 +43,7 @@ import { Chromecast } from "./Chromecast";
import { ItemHeader } from "./ItemHeader";
import { Loader } from "./Loader";
import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
const [api] = useAtom(apiAtom);
@@ -56,7 +57,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
useState<MediaSourceInfo | null>(null);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
useState<number>(-1);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
@@ -124,6 +125,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
});
const [localItem, setLocalItem] = useState(item);
useImageColors(item);
useEffect(() => {
if (item) {
@@ -167,7 +169,7 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
if (item?.Type === "Episode") headerHeightRef.current = 400;
else if (item?.Type === "Movie") headerHeightRef.current = 500;
else headerHeightRef.current = 400;
}, [item]);
}, [item, orientation]);
const { data: sessionData } = useQuery({
queryKey: ["sessionData", item?.Id],
@@ -233,18 +235,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
});
const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const themeImageColorSource = useMemo(() => {
if (!api || !item) return;
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
}, [api, item]);
useImageColors(themeImageColorSource?.uri);
const loading = useMemo(() => {
return Boolean(isLoading || isFetching || (logoUrl && loadingLogo));
@@ -273,7 +263,6 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
<Animated.View style={[animatedStyle, { flex: 1 }]}>
{localItem && (
<ItemImage
useThemeColor
variant={
localItem.Type === "Movie" && logoUrl
? "Backdrop"
@@ -356,10 +345,23 @@ export const ItemContent: React.FC<{ id: string }> = React.memo(({ id }) => {
<SeasonEpisodesCarousel item={item} loading={loading} />
)}
<OverviewText text={item?.Overview} className="px-4 mb-4" />
<OverviewText text={item?.Overview} className="px-4 my-4" />
<CastAndCrew item={item} className="mb-4" loading={loading} />
{item?.People && item.People.length > 0 && (
<View className="mb-4">
{item.People.slice(0, 3).map((person) => (
<MoreMoviesWithActor
currentItem={item}
key={person.Id}
actorId={person.Id!}
className="mb-4"
/>
))}
</View>
)}
{item?.Type === "Episode" && (
<CurrentSeries item={item} className="mb-4" />
)}

View File

@@ -3,6 +3,8 @@ import { View, ViewProps } from "react-native";
import { MoviesTitleHeader } from "./movies/MoviesTitleHeader";
import { Ratings } from "./Ratings";
import { EpisodeTitleHeader } from "./series/EpisodeTitleHeader";
import { GenreTags } from "./GenreTags";
import React from "react";
interface Props extends ViewProps {
item?: BaseItemDto | null;
@@ -12,7 +14,7 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
if (!item)
return (
<View
className="flex flex-col space-y-1.5 w-full items-start h-24"
className="flex flex-col space-y-1.5 w-full items-start h-32"
{...props}
>
<View className="w-1/3 h-6 bg-neutral-900 rounded" />
@@ -23,16 +25,22 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
);
return (
<View
style={{
minHeight: 96,
}}
className="flex flex-col"
{...props}
>
<Ratings item={item} className="mb-2" />
{item.Type === "Episode" && <EpisodeTitleHeader item={item} />}
{item.Type === "Movie" && <MoviesTitleHeader item={item} />}
<View className="flex flex-col" {...props}>
<View className="flex flex-col" {...props}>
<Ratings item={item} className="mb-2" />
{item.Type === "Episode" && (
<>
<EpisodeTitleHeader item={item} />
<GenreTags genres={item.Genres!} />
</>
)}
{item.Type === "Movie" && (
<>
<MoviesTitleHeader item={item} />
<GenreTags genres={item.Genres!} />
</>
)}
</View>
</View>
);
};

View File

@@ -1,19 +0,0 @@
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { PropsWithChildren } from "react";
interface Props extends ViewProps {}
export const List: React.FC<PropsWithChildren<Props>> = ({
children,
...props
}) => {
return (
<View
className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800"
{...props}
>
{children}
</View>
);
};

View File

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

View File

@@ -7,6 +7,7 @@ import { useEffect, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { convertBitsToMegabitsOrGigabits } from "@/utils/bToMb";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
@@ -78,7 +79,9 @@ export const MediaSourceSelector: React.FC<Props> = ({
}}
>
<DropdownMenu.ItemTitle>
{name(source.Name)}
{`${name(source.Name)} - ${convertBitsToMegabitsOrGigabits(
source.Size
)}`}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}

View File

@@ -0,0 +1,100 @@
import React from "react";
import { View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import MoviePoster from "@/components/posters/MoviePoster";
import { ItemCardText } from "@/components/ItemCardText";
import { useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
interface Props extends ViewProps {
actorId: string;
currentItem: BaseItemDto;
}
export const MoreMoviesWithActor: React.FC<Props> = ({
actorId,
currentItem,
...props
}) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: actor } = useQuery({
queryKey: ["actor", actorId],
queryFn: async () => {
if (!api || !user?.Id) return null;
return await getUserItemData({
api,
userId: user.Id,
itemId: actorId,
});
},
enabled: !!api && !!user?.Id && !!actorId,
});
const { data: items, isLoading } = useQuery({
queryKey: ["actor", "movies", actorId, currentItem.Id],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
personIds: [actorId],
limit: 20,
sortOrder: ["Descending"],
includeItemTypes: ["Movie", "Series"],
recursive: true,
fields: ["ParentId", "PrimaryImageAspectRatio"],
sortBy: ["PremiereDate"],
collapseBoxSetItems: false,
excludeItemIds: [currentItem.SeriesId || "", currentItem.Id || ""],
});
// Remove duplicates based on item ID
const uniqueItems =
response.data.Items?.reduce((acc, current) => {
const x = acc.find((item) => item.Id === current.Id);
if (!x) {
return acc.concat([current]);
} else {
return acc;
}
}, [] as BaseItemDto[]) || [];
return uniqueItems;
},
enabled: !!api && !!user?.Id && !!actorId,
});
if (items?.length === 0) return null;
return (
<View {...props}>
<Text className="text-lg font-bold mb-2 px-4">
More with {actor?.Name}
</Text>
<HorizontalScroll
data={items}
loading={isLoading}
height={247}
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/>
</View>
);
};

View File

@@ -19,7 +19,7 @@ export const OverviewText: React.FC<Props> = ({
return (
<View className="flex flex-col" {...props}>
<Text className="text-xl font-bold mb-2">Overview</Text>
<Text className="text-lg font-bold mb-2">Overview</Text>
<TouchableOpacity
onPress={() =>
setLimit((prev) =>

View File

@@ -0,0 +1,35 @@
import { BlurView } from "expo-blur";
import React from "react";
import { Platform, View, ViewProps } from "react-native";
interface Props extends ViewProps {
blurAmount?: number;
blurType?: "light" | "dark" | "xlight";
}
/**
* BlurView for iOS and simple View for Android
*/
export const PlatformBlurView: React.FC<Props> = ({
blurAmount = 100,
blurType = "light",
style,
children,
...props
}) => {
if (Platform.OS === "ios") {
return (
<BlurView style={style} intensity={blurAmount} {...props}>
{children}
</BlurView>
);
}
return (
<View
style={[{ backgroundColor: "rgba(50, 50, 50, 0.9)" }, style]}
{...props}
>
{children}
</View>
);
};

View File

@@ -27,6 +27,7 @@ import Animated, {
} from "react-native-reanimated";
import { Button } from "./Button";
import { Text } from "./common/Text";
import { useRouter } from "expo-router";
interface Props extends React.ComponentProps<typeof Button> {
item?: BaseItemDto | null;
@@ -45,6 +46,8 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const [colorAtom] = useAtom(itemThemeColorAtom);
const [api] = useAtom(apiAtom);
const router = useRouter();
const memoizedItem = useMemo(() => item, [item?.Id]); // Memoize the item
const memoizedColor = useMemo(() => colorAtom, [colorAtom]); // Memoize the color
@@ -57,12 +60,13 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
const directStream = useMemo(() => {
return !url?.includes("m3u8");
}, []);
}, [url]);
const onPress = async () => {
if (!url || !item) return;
if (!client) {
setCurrentlyPlayingState({ item, url });
router.push("/play");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
@@ -160,6 +164,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
break;
case 1:
setCurrentlyPlayingState({ item, url });
router.push("/play");
break;
case cancelButtonIndex:
break;
@@ -226,7 +231,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.average, endColor.value.average]
[startColor.value.primary, endColor.value.primary]
),
}));
@@ -279,7 +284,7 @@ export const PlayButton: React.FC<Props> = ({ item, url, ...props }) => {
</View>
<Animated.View
style={[animatedAverageStyle]}
style={[animatedAverageStyle, { opacity: 0.5 }]}
className="absolute w-full h-full top-0 left-0 rounded-xl"
/>
<View

View File

@@ -21,11 +21,17 @@ export const PlayedStatus: React.FC<Props> = ({ item, ...props }) => {
const invalidateQueries = () => {
queryClient.invalidateQueries({
queryKey: ["item"],
queryKey: ["item", item.Id],
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
queryClient.invalidateQueries({
queryKey: ["continueWatching"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp"],
});

View File

@@ -10,6 +10,8 @@ import { ScrollView, TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "./common/Text";
import { ItemCardText } from "./ItemCardText";
import { Loader } from "./Loader";
import { HorizontalScroll } from "./common/HorrizontalScroll";
import { TouchableItemRouter } from "./common/TouchableItemRouter";
interface SimilarItemsProps extends ViewProps {
itemId?: string | null;
@@ -46,29 +48,24 @@ export const SimilarItems: React.FC<SimilarItemsProps> = ({
return (
<View {...props}>
<Text className="px-4 text-lg font-bold mb-2">Similar items</Text>
{isLoading ? (
<View className="my-12">
<Loader />
</View>
) : (
<ScrollView horizontal>
<View className="px-4 flex flex-row gap-x-2">
{movies.map((item) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/items/page?id=${item.Id}`)}
className="flex flex-col w-32"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
{movies.length === 0 && (
<Text className="px-4 text-neutral-500">No similar items</Text>
)}
<HorizontalScroll
data={movies}
loading={isLoading}
height={247}
noItemsText="No similar items found"
renderItem={(item: BaseItemDto, idx: number) => (
<TouchableItemRouter
key={idx}
item={item}
className="flex flex-col w-28"
>
<View>
<MoviePoster item={item} />
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/>
</View>
);
};

View File

@@ -1,4 +1,5 @@
import {
Platform,
TouchableOpacity,
TouchableOpacityProps,
View,
@@ -21,7 +22,7 @@ export const HeaderBackButton: React.FC<Props> = ({
}) => {
const router = useRouter();
if (background === "transparent")
if (background === "transparent" && Platform.OS !== "android")
return (
<BlurView
{...props}
@@ -52,7 +53,7 @@ export const HeaderBackButton: React.FC<Props> = ({
className="drop-shadow-2xl"
name="arrow-back"
size={24}
color="#077DF2"
color="white"
/>
</TouchableOpacity>
);

View File

@@ -22,6 +22,7 @@ interface HorizontalScrollProps<T>
height?: number;
loading?: boolean;
extraData?: any;
noItemsText?: string;
}
export const HorizontalScroll = forwardRef<
@@ -38,6 +39,7 @@ export const HorizontalScroll = forwardRef<
loading = false,
height = 164,
extraData,
noItemsText,
...props
}: HorizontalScrollProps<T>,
ref: React.ForwardedRef<HorizontalScrollRef>
@@ -91,10 +93,11 @@ export const HorizontalScroll = forwardRef<
}}
ListEmptyComponent={() => (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-gray-500">No data available</Text>
<Text className="text-center text-gray-500">
{noItemsText || "No data available"}
</Text>
</View>
)}
{...props}
/>
);
}

View File

@@ -22,7 +22,6 @@ interface Props extends ImageProps {
| "Thumb";
quality?: number;
width?: number;
useThemeColor?: boolean;
onError?: () => void;
}
@@ -31,7 +30,6 @@ export const ItemImage: React.FC<Props> = ({
variant = "Primary",
quality = 90,
width = 1000,
useThemeColor = false,
onError,
...props
}) => {

View File

@@ -8,6 +8,42 @@ interface Props extends TouchableOpacityProps {
item: BaseItemDto;
}
export const itemRouter = (item: BaseItemDto, from: string) => {
if (item.Type === "Series") {
return `/(auth)/(tabs)/${from}/series/${item.Id}`;
}
if (item.Type === "MusicAlbum") {
return `/(auth)/(tabs)/${from}/albums/${item.Id}`;
}
if (item.Type === "Audio") {
return `/(auth)/(tabs)/${from}/albums/${item.AlbumId}`;
}
if (item.Type === "MusicArtist") {
return `/(auth)/(tabs)/${from}/artists/${item.Id}`;
}
if (item.Type === "Person") {
return `/(auth)/(tabs)/${from}/actors/${item.Id}`;
}
if (item.Type === "BoxSet") {
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
}
if (item.Type === "UserView") {
return `/(auth)/(tabs)/${from}/collections/${item.Id}`;
}
if (item.Type === "CollectionFolder") {
return `/(auth)/(tabs)/(libraries)/${item.Id}`;
}
return `/(auth)/(tabs)/${from}/items/page?id=${item.Id}`;
};
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item,
children,
@@ -23,54 +59,9 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (item.Type === "Series") {
router.push(`/(auth)/(tabs)/${from}/series/${item.Id}`);
return;
}
if (item.Type === "MusicAlbum") {
router.push(`/(auth)/(tabs)/${from}/albums/${item.Id}`);
return;
}
if (item.Type === "Audio") {
router.push(`/(auth)/(tabs)/${from}/albums/${item.AlbumId}`);
return;
}
if (item.Type === "MusicArtist") {
router.push(`/(auth)/(tabs)/${from}/artists/${item.Id}`);
return;
}
if (item.Type === "Person") {
router.push(`/(auth)/(tabs)/${from}/actors/${item.Id}`);
return;
}
if (item.Type === "BoxSet") {
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
return;
}
if (item.Type === "UserView") {
router.push(`/(auth)/(tabs)/${from}/collections/${item.Id}`);
return;
}
if (item.Type === "CollectionFolder") {
router.push(`/(auth)/(tabs)/(libraries)/${item.Id}`);
return;
}
// Same as default
// if (item.Type === "Episode") {
// router.push(`/items/${item.Id}`);
// return;
// }
router.push(`/(auth)/(tabs)/${from}/items/page?id=${item.Id}`);
const url = itemRouter(item, from);
// @ts-ignore
router.push(url);
}}
{...props}
>

View File

@@ -0,0 +1,191 @@
import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time";
import { Ionicons } from "@expo/vector-icons";
import { checkForExistingDownloads } from "@kesha-antonov/react-native-background-downloader";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { FFmpegKit } from "ffmpeg-kit-react-native";
import { useAtom } from "jotai";
import {
ActivityIndicator,
TouchableOpacity,
TouchableOpacityProps,
View,
ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import { Button } from "../Button";
import { Image } from "expo-image";
import { useMemo } from "react";
import { storage } from "@/utils/mmkv";
interface Props extends ViewProps {}
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes, startDownload } = useDownload();
if (processes?.length === 0)
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold">Active download</Text>
<Text className="opacity-50">No active downloads</Text>
</View>
);
return (
<View {...props} className="bg-neutral-900 p-4 rounded-2xl">
<Text className="text-lg font-bold mb-2">Active downloads</Text>
<View className="space-y-2">
{processes?.map((p) => (
<DownloadCard key={p.id} process={p} />
))}
</View>
</View>
);
};
interface DownloadCardProps extends TouchableOpacityProps {
process: JobStatus;
}
const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { processes, startDownload } = useDownload();
const router = useRouter();
const { removeProcess, setProcesses } = useDownload();
const [settings] = useSettings();
const queryClient = useQueryClient();
const cancelJobMutation = useMutation({
mutationFn: async (id: string) => {
if (!process) throw new Error("No active download");
if (settings?.downloadMethod === "optimized") {
try {
const tasks = await checkForExistingDownloads();
for (const task of tasks) {
if (task.id === id) {
task.stop();
}
}
} catch (e) {
throw e;
} finally {
await removeProcess(id);
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
} else {
FFmpegKit.cancel();
setProcesses((prev) => prev.filter((p) => p.id !== id));
}
},
onSuccess: () => {
toast.success("Download canceled");
},
onError: (e) => {
console.log(e);
toast.error("Could not cancel download");
},
});
const eta = (p: JobStatus) => {
if (!p.speed || !p.progress) return null;
const length = p?.item?.RunTimeTicks || 0;
const timeLeft = (length - length * (p.progress / 100)) / p.speed;
return formatTimeString(timeLeft, true);
};
const base64Image = useMemo(() => {
return storage.getString(process.item.Id!);
}, []);
return (
<TouchableOpacity
onPress={() => router.push(`/(auth)/items/page?id=${process.item.Id}`)}
className="relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden"
{...props}
>
{(process.status === "optimizing" ||
process.status === "downloading") && (
<View
className={`
bg-purple-600 h-1 absolute bottom-0 left-0
`}
style={{
width: process.progress
? `${Math.max(5, process.progress)}%`
: "5%",
}}
></View>
)}
<View className="px-3 py-1.5 flex flex-col w-full">
<View className="flex flex-row items-center w-full">
{base64Image && (
<View className="w-14 aspect-[10/15] rounded-lg overflow-hidden mr-4">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View>
)}
<View className="shrink mb-1">
<Text className="text-xs opacity-50">{process.item.Type}</Text>
<Text className="font-semibold shrink">{process.item.Name}</Text>
<Text className="text-xs opacity-50">
{process.item.ProductionYear}
</Text>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<Text className="text-xs">{process.progress.toFixed(0)}%</Text>
)}
{process.speed && (
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
)}
{eta(process) && (
<Text className="text-xs">ETA {eta(process)}</Text>
)}
</View>
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
<Text className="text-xs capitalize">{process.status}</Text>
</View>
</View>
<TouchableOpacity
disabled={cancelJobMutation.isPending}
onPress={() => cancelJobMutation.mutate(process.id)}
className="ml-auto"
>
{cancelJobMutation.isPending ? (
<ActivityIndicator size="small" color="white" />
) : (
<Ionicons name="close" size={24} color="red" />
)}
</TouchableOpacity>
</View>
{process.status === "completed" && (
<View className="flex flex-row mt-4 space-x-4">
<Button
onPress={() => {
startDownload(process);
}}
className="w-full"
>
Download now
</Button>
</View>
)}
</View>
</TouchableOpacity>
);
};

View File

@@ -1,36 +1,41 @@
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 React, { useCallback, useMemo, useRef } from "react";
import { TouchableOpacity, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Image } from "expo-image";
import { ItemCardText } from "../ItemCardText";
import { Ionicons } from "@expo/vector-icons";
interface EpisodeCardProps {
item: BaseItemDto;
}
/**
* EpisodeCard component displays an episode with context menu options.
* EpisodeCard component displays an episode with action sheet 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 { deleteFile } = useDownload();
const { openFile } = useFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const { startDownloadedFilePlayback } = usePlayback();
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
}, []);
const handleOpenFile = useCallback(async () => {
startDownloadedFilePlayback({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, startDownloadedFilePlayback]);
const handleOpenFile = useCallback(() => {
openFile(item);
}, [item, openFile]);
/**
* Handles deleting the file with haptic feedback.
@@ -42,43 +47,70 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
}
}, [deleteFile, item.Id]);
const contextMenuOptions = [
{
label: "Delete",
onSelect: handleDeleteFile,
destructive: true,
},
];
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
const cancelButtonIndex = 1;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
destructiveButtonIndex,
},
(selectedIndex) => {
switch (selectedIndex) {
case destructiveButtonIndex:
// Delete
handleDeleteFile();
break;
case cancelButtonIndex:
// Cancelled
break;
}
}
);
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
<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>
<TouchableOpacity
onPress={handleOpenFile}
onLongPress={showActionSheet}
className="flex flex-col"
>
{base64Image ? (
<View className="w-44 aspect-video rounded-lg overflow-hidden mr-2">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View>
) : (
<View className="w-44 aspect-video rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
<ItemCardText item={item} />
</TouchableOpacity>
);
};
// Wrap the parent component with ActionSheetProvider
export const EpisodeCardWithActionSheet: React.FC<EpisodeCardProps> = (
props
) => (
<ActionSheetProvider>
<EpisodeCard {...props} />
</ActionSheetProvider>
);

View File

@@ -1,39 +1,43 @@
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 React, { useCallback, useMemo } from "react";
import { TouchableOpacity, View } from "react-native";
import {
ActionSheetProvider,
useActionSheet,
} from "@expo/react-native-action-sheet";
import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles";
import { runtimeTicksToMinutes } from "@/utils/time";
import { Text } from "../common/Text";
import { useSettings } from "@/utils/atoms/settings";
import { usePlayback } from "@/providers/PlaybackProvider";
import { useFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Image } from "expo-image";
import { Ionicons } from "@expo/vector-icons";
import { ItemCardText } from "../ItemCardText";
interface MovieCardProps {
item: BaseItemDto;
}
/**
* MovieCard component displays a movie with context menu options.
* MovieCard component displays a movie with action sheet options.
* @param {MovieCardProps} props - The component props.
* @returns {React.ReactElement} The rendered MovieCard component.
*/
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useFiles();
const [settings] = useSettings();
const { startDownloadedFilePlayback } = usePlayback();
const { deleteFile } = useDownload();
const { openFile } = useFileOpener();
const { showActionSheetWithOptions } = useActionSheet();
const handleOpenFile = useCallback(() => {
startDownloadedFilePlayback({
item,
url: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item, startDownloadedFilePlayback]);
openFile(item);
}, [item, openFile]);
const base64Image = useMemo(() => {
return storage.getString(item.Id!);
}, []);
/**
* Handles deleting the file with haptic feedback.
@@ -45,48 +49,64 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
}
}, [deleteFile, item.Id]);
const contextMenuOptions = [
{
label: "Delete",
onSelect: handleDeleteFile,
destructive: true,
},
];
const showActionSheet = useCallback(() => {
const options = ["Delete", "Cancel"];
const destructiveButtonIndex = 0;
const cancelButtonIndex = 1;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
destructiveButtonIndex,
},
(selectedIndex) => {
switch (selectedIndex) {
case destructiveButtonIndex:
// Delete
handleDeleteFile();
break;
case cancelButtonIndex:
// Cancelled
break;
}
}
);
}, [showActionSheetWithOptions, handleDeleteFile]);
return (
<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>
<TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
{base64Image ? (
<View className="w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900">
<Image
source={{
uri: `data:image/jpeg;base64,${base64Image}`,
}}
style={{
width: "100%",
height: "100%",
resizeMode: "cover",
}}
/>
</View>
) : (
<View className="w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center">
<Ionicons
name="image-outline"
size={24}
color="gray"
className="self-center mt-16"
/>
</View>
)}
<ItemCardText item={item} />
</TouchableOpacity>
);
};
// Wrap the parent component with ActionSheetProvider
export const MovieCardWithActionSheet: React.FC<MovieCardProps> = (props) => (
<ActionSheetProvider>
<MovieCard {...props} />
</ActionSheetProvider>
);

View File

@@ -1,5 +1,5 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { View } from "react-native";
import { ScrollView, View } from "react-native";
import { EpisodeCard } from "./EpisodeCard";
import { Text } from "../common/Text";
import { useMemo } from "react";
@@ -22,26 +22,32 @@ export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
);
}, [items]);
const sortByIndex = (a: BaseItemDto, b: BaseItemDto) => {
return a.IndexNumber! > b.IndexNumber! ? 1 : -1;
};
return (
<View>
<View className="flex flex-row items-center justify-between">
<Text className="text-2xl font-bold shrink">{items[0].SeriesName}</Text>
<View className="flex flex-row items-center justify-between px-4">
<Text className="text-lg font-bold shrink">{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>
<Text className="opacity-50 mb-2 px-4">TV-Series</Text>
{groupBySeason.map((seasonItems, seasonIndex) => (
<View key={seasonIndex}>
<Text className="mb-2 font-semibold">
<Text className="mb-2 font-semibold px-4">
{seasonItems[0].SeasonName}
</Text>
{seasonItems.map((item, index) => (
<View className="mb-2" key={index}>
<EpisodeCard item={item} />
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{seasonItems.sort(sortByIndex)?.map((item, index) => (
<EpisodeCard item={item} />
))}
</View>
))}
</ScrollView>
</View>
))}
</View>

View File

@@ -4,25 +4,29 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, { useMemo } from "react";
import { Dimensions, View, ViewProps } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import React, { useCallback, useMemo } from "react";
import { Dimensions, TouchableOpacity, View, ViewProps } from "react-native";
import Animated, {
runOnJS,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import Carousel, {
ICarouselInstance,
Pagination,
} from "react-native-reanimated-carousel";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { itemRouter, TouchableItemRouter } from "../common/TouchableItemRouter";
import { Loader } from "../Loader";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useRouter, useSegments } from "expo-router";
import * as Haptics from "expo-haptics";
interface Props extends ViewProps {}
export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const router = useRouter();
const queryClient = useQueryClient();
const [settings] = useSettings();
const ref = React.useRef<ICarouselInstance>(null);
@@ -80,13 +84,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const width = Dimensions.get("screen").width;
if (l1 || l2)
return (
<View className="h-[242px] flex items-center justify-center">
<Loader />
</View>
);
if (l1 || l2) return null;
if (!popularItems) return null;
return (
@@ -122,7 +120,7 @@ export const LargeMovieCarousel: React.FC<Props> = ({ ...props }) => {
const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const [api] = useAtom(apiAtom);
const router = useRouter();
const screenWidth = Dimensions.get("screen").width;
const uri = useMemo(() => {
@@ -141,11 +139,41 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
return getLogoImageUrlById({ api, item, height: 100 });
}, [item]);
const segments = useSegments();
const from = segments[2];
const opacity = useSharedValue(1);
const handleRoute = useCallback(() => {
if (!from) return;
const url = itemRouter(item, from);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// @ts-ignore
if (url) router.push(url);
}, [item, from]);
const tap = Gesture.Tap()
.maxDuration(2000)
.onBegin(() => {
opacity.value = withTiming(0.5, { duration: 100 });
})
.onEnd(() => {
runOnJS(handleRoute)();
})
.onFinalize(() => {
opacity.value = withTiming(1, { duration: 100 });
});
if (!uri || !logoUri) return null;
return (
<TouchableItemRouter item={item}>
<View className="px-4">
<GestureDetector gesture={tap}>
<Animated.View
style={{
opacity: opacity,
}}
className="px-4"
>
<View className="relative flex justify-center rounded-2xl overflow-hidden border border-neutral-800">
<Image
source={{
@@ -171,7 +199,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
/>
</View>
</View>
</View>
</TouchableItemRouter>
</Animated.View>
</GestureDetector>
);
};

View File

@@ -1,23 +1,22 @@
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import { useSettings } from "@/utils/atoms/settings";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
useQuery,
type QueryFunction,
type QueryKey,
} from "@tanstack/react-query";
import { View, ViewProps } from "react-native";
import { ScrollView, View, ViewProps } from "react-native";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import SeriesPoster from "../posters/SeriesPoster";
import { FlashList } from "@shopify/flash-list";
interface Props extends ViewProps {
title?: string | null;
orientation?: "horizontal" | "vertical";
height?: "small" | "large";
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>;
@@ -26,13 +25,11 @@ interface Props extends ViewProps {
export const ScrollingCollectionList: React.FC<Props> = ({
title,
orientation = "vertical",
height = "small",
disabled = false,
queryFn,
queryKey,
...props
}) => {
const [settings] = useSettings();
const { data, isLoading } = useQuery({
queryKey,
queryFn,
@@ -43,41 +40,70 @@ export const ScrollingCollectionList: React.FC<Props> = ({
if (disabled || !title) return null;
return (
<View {...props}>
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
<View {...props} className="">
<Text className="px-4 text-lg font-bold mb-2 text-neutral-100">
{title}
</Text>
<HorizontalScroll
data={data}
height={orientation === "vertical" ? 247 : 164}
loading={isLoading}
renderItem={(item, index) => (
<TouchableItemRouter
key={index}
item={item}
className={`flex flex-col
{isLoading ? (
<View
className={`
flex flex-row gap-2 px-4
`}
>
{[1, 2, 3].map((i) => (
<View className="w-44" key={i}>
<View className="bg-neutral-900 h-24 w-full rounded-md mb-1"></View>
<View className="rounded-md overflow-hidden mb-1 self-start">
<Text
className="text-neutral-900 bg-neutral-900 rounded-md"
numberOfLines={1}
>
Nisi mollit voluptate amet.
</Text>
</View>
<View className="rounded-md overflow-hidden self-start mb-1">
<Text
className="text-neutral-900 bg-neutral-900 text-xs rounded-md "
numberOfLines={1}
>
Lorem ipsum
</Text>
</View>
</View>
))}
</View>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className="px-4 flex flex-row">
{data?.map((item, index) => (
<TouchableItemRouter
item={item}
key={index}
className={`
mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
>
<View>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Movie" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Movie" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Series" && <SeriesPoster item={item} />}
<ItemCardText item={item} />
</View>
</TouchableItemRouter>
)}
/>
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Movie" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Movie" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Series" && <SeriesPoster item={item} />}
<ItemCardText item={item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
)}
</View>
);
};

View File

@@ -125,7 +125,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
{library.Name}
</Text>
{settings?.libraryOptions?.showStats && (
<Text className="font-bold text-xs text-neutral-500 text-start px-4 ml-auto">
<Text className="font-bold text-xs text-neutral-500 text-start ml-auto">
{itemsCount} items
</Text>
)}

View File

@@ -9,7 +9,9 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text className=" font-bold text-2xl mb-1">{item?.Name}</Text>
<Text className=" font-bold text-2xl mb-1" selectable>
{item?.Name}
</Text>
<Text className=" opacity-50">{item?.ProductionYear}</Text>
</View>
);

View File

@@ -8,6 +8,7 @@ import { runtimeTicksToSeconds } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { TouchableOpacity, TouchableOpacityProps, View } from "react-native";
import CastContext, {
@@ -35,7 +36,7 @@ export const SongsListItem: React.FC<Props> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const router = useRouter();
const client = useRemoteMediaClient();
const { showActionSheetWithOptions } = useActionSheet();
@@ -123,6 +124,7 @@ export const SongsListItem: React.FC<Props> = ({
item,
url,
});
router.push("/play-music");
}
};

View File

@@ -36,7 +36,7 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
}, [item]);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<View className="relative rounded-lg overflow-hidden border border-neutral-900 w-28 aspect-[10/15]">
<Image
placeholder={{
blurhash,
@@ -57,7 +57,6 @@ const MoviePoster: React.FC<MoviePosterProps> = ({
width: "100%",
}}
/>
<WatchedIndicator item={item} />
{showProgress && progress > 0 && (
<View className="h-1 bg-red-600 w-full"></View>

View File

@@ -32,7 +32,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
}, [item]);
return (
<View className="relative rounded-lg overflow-hidden border border-neutral-900">
<View className="w-28 aspect-[10/15] relative rounded-lg overflow-hidden border border-neutral-900 ">
<Image
placeholder={{
blurhash,
@@ -49,7 +49,7 @@ const SeriesPoster: React.FC<MoviePosterProps> = ({ item }) => {
cachePolicy={"memory-disk"}
contentFit="cover"
style={{
aspectRatio: "10/15",
height: "100%",
width: "100%",
}}
/>

View File

@@ -25,6 +25,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
<Text className="text-lg font-bold mb-2 px-4">Cast & Crew</Text>
<HorizontalScroll
loading={loading}
height={247}
data={item?.People || []}
renderItem={(item, index) => (
<TouchableOpacity
@@ -32,7 +33,7 @@ export const CastAndCrew: React.FC<Props> = ({ item, loading, ...props }) => {
router.push(`/actors/${item.Id}`);
}}
key={item.Id}
className="flex flex-col w-32"
className="flex flex-col w-28"
>
<Poster item={item} url={getPrimaryImageUrl({ api, item })} />
<Text className="mt-2">{item.Name}</Text>

View File

@@ -21,11 +21,12 @@ export const CurrentSeries: React.FC<Props> = ({ item, ...props }) => {
<Text className="text-lg font-bold mb-2 px-4">Series</Text>
<HorizontalScroll
data={[item]}
height={247}
renderItem={(item, index) => (
<TouchableOpacity
key={item.Id}
onPress={() => router.push(`/series/${item.SeriesId}`)}
className="flex flex-col space-y-2 w-32"
className="flex flex-col space-y-2 w-28"
>
<Poster
item={item}

View File

@@ -12,7 +12,9 @@ export const EpisodeTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return (
<View {...props}>
<Text className="font-bold text-2xl">{item?.Name}</Text>
<Text className="font-bold text-2xl" selectable>
{item?.Name}
</Text>
<View className="flex flex-row items-center mb-1">
<TouchableOpacity
onPress={() => {

View File

@@ -10,6 +10,7 @@ import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
const [user] = useAtom(userAtom);
@@ -46,16 +47,14 @@ export const NextUp: React.FC<{ seriesId: string }> = ({ seriesId }) => {
<HorizontalScroll
data={items}
renderItem={(item, index) => (
<TouchableOpacity
onPress={() => {
router.push(`/(auth)/items/page?id=${item.Id}`);
}}
key={item.Id}
<TouchableItemRouter
item={item}
key={index}
className="flex flex-col w-44"
>
<ContinueWatchingPoster item={item} useEpisodePoster />
<ItemCardText item={item} />
</TouchableOpacity>
</TouchableItemRouter>
)}
/>
</View>

View File

@@ -15,6 +15,7 @@ import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { Image } from "expo-image";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
type Props = {
item: BaseItemDto;
@@ -192,18 +193,16 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
</View>
) : (
episodes?.map((e: BaseItemDto) => (
<TouchableOpacity
<TouchableItemRouter
item={e}
key={e.Id}
onPress={() => {
router.push(`/(auth)/items/page?id=${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">
<View className="flex flex-row items-start mb-2">
<View className="mr-2">
<ContinueWatchingPoster
size="small"
item={e}
width={128}
useEpisodePoster
/>
</View>
@@ -218,7 +217,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
{runtimeTicksToSeconds(e.RunTimeTicks)}
</Text>
</View>
<View className="self-start ml-auto">
<View className="self-start ml-auto -mt-0.5">
<DownloadItem item={e} />
</View>
</View>
@@ -229,7 +228,7 @@ export const SeasonPicker: React.FC<Props> = ({ item, initialSeasonIndex }) => {
>
{e.Overview}
</Text>
</TouchableOpacity>
</TouchableItemRouter>
))
)}
</View>

View File

@@ -1,39 +1,16 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import {
DefaultLanguageOption,
DownloadOptions,
useSettings,
} from "@/utils/atoms/settings";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import {
Linking,
Switch,
TouchableOpacity,
View,
ViewProps,
} from "react-native";
import { useSettings } from "@/utils/atoms/settings";
import { TouchableOpacity, View, ViewProps } 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";
const LANGUAGES: DefaultLanguageOption[] = [
{ label: "eng", value: "eng" },
{
label: "sv",
value: "sv",
},
];
import { LANGUAGES } from "@/constants/Languages";
interface Props extends ViewProps {}
export const MediaToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
if (!settings) return null;
return (
<View>
<Text className="text-lg font-bold mb-2">Media</Text>
@@ -144,6 +121,82 @@ export const MediaToggles: React.FC<Props> = ({ ...props }) => {
</DropdownMenu.Content>
</DropdownMenu.Root>
</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">Forward skip length</Text>
<Text className="text-xs opacity-50">
Choose length in seconds when skipping in video playback.
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
forwardSkipTime: Math.max(0, settings.forwardSkipTime - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.forwardSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
forwardSkipTime: Math.min(60, settings.forwardSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</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">Rewind length</Text>
<Text className="text-xs opacity-50">
Choose length in seconds when skipping in video playback.
</Text>
</View>
<View className="flex flex-row items-center">
<TouchableOpacity
onPress={() =>
updateSettings({
rewindSkipTime: Math.max(0, settings.rewindSkipTime - 5),
})
}
className="w-8 h-8 bg-neutral-800 rounded-l-lg flex items-center justify-center"
>
<Text>-</Text>
</TouchableOpacity>
<Text className="w-12 h-8 bg-neutral-800 first-letter:px-3 py-2 flex items-center justify-center">
{settings.rewindSkipTime}s
</Text>
<TouchableOpacity
className="w-8 h-8 bg-neutral-800 rounded-r-lg flex items-center justify-center"
onPress={() =>
updateSettings({
rewindSkipTime: Math.min(60, settings.rewindSkipTime + 5),
})
}
>
<Text>+</Text>
</TouchableOpacity>
</View>
</View>
</View>
</View>
);

View File

@@ -1,31 +1,85 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useDownload } from "@/providers/DownloadProvider";
import {
DefaultLanguageOption,
DownloadOptions,
useSettings,
} from "@/utils/atoms/settings";
apiAtom,
getOrSetDeviceId,
userAtom,
} from "@/providers/JellyfinProvider";
import { ScreenOrientationEnum, useSettings } from "@/utils/atoms/settings";
import {
BACKGROUND_FETCH_TASK,
registerBackgroundFetchAsync,
unregisterBackgroundFetchAsync,
} from "@/utils/background-tasks";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as BackgroundFetch from "expo-background-fetch";
import * as ScreenOrientation from "expo-screen-orientation";
import * as TaskManager from "expo-task-manager";
import { useAtom } from "jotai";
import { Linking, Switch, TouchableOpacity, View } from "react-native";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Linking,
Switch,
TouchableOpacity,
View,
ViewProps,
} from "react-native";
import { toast } from "sonner-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Button } from "../Button";
import { Input } from "../common/Input";
import { Text } from "../common/Text";
import { Loader } from "../Loader";
import { Input } from "../common/Input";
import { useState } from "react";
import { Button } from "../Button";
import { MediaToggles } from "./MediaToggles";
import axios from "axios";
import { getStatistics } from "@/utils/optimize-server";
export const SettingToggles: React.FC = () => {
interface Props extends ViewProps {}
export const SettingToggles: React.FC<Props> = ({ ...props }) => {
const [settings, updateSettings] = useSettings();
const { setProcesses } = useDownload();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [marlinUrl, setMarlinUrl] = useState<string>("");
const [optimizedVersionsServerUrl, setOptimizedVersionsServerUrl] =
useState<string>(settings?.optimizedVersionsServerUrl || "");
const queryClient = useQueryClient();
/********************
* Background task
*******************/
const checkStatusAsync = async () => {
await BackgroundFetch.getStatusAsync();
return await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
};
useEffect(() => {
(async () => {
const registered = await checkStatusAsync();
if (settings?.autoDownload === true && !registered) {
registerBackgroundFetchAsync();
toast.success("Background downlodas enabled");
} else if (settings?.autoDownload === false && registered) {
unregisterBackgroundFetchAsync();
toast.info("Background downloads disabled");
} else if (settings?.autoDownload === true && registered) {
// Don't to anything
} else if (settings?.autoDownload === false && !registered) {
// Don't to anything
} else {
updateSettings({ autoDownload: false });
}
})();
}, [settings?.autoDownload]);
/**********************
*********************/
const {
data: mediaListCollections,
isLoading: isLoadingMediaListCollections,
@@ -48,8 +102,10 @@ export const SettingToggles: React.FC = () => {
staleTime: 0,
});
if (!settings) return null;
return (
<View>
<View {...props}>
{/* <View>
<Text className="text-lg font-bold mb-2">Look and feel</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800 opacity-50">
@@ -70,7 +126,7 @@ export const SettingToggles: React.FC = () => {
<View>
<Text className="text-lg font-bold mb-2">Other</Text>
<View className="flex flex-col rounded-xl mb-4 overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<View className="flex flex-row items-center justify-between bg-neutral-900 p-4">
<View className="shrink">
<Text className="font-semibold">Auto rotate</Text>
@@ -80,25 +136,116 @@ export const SettingToggles: React.FC = () => {
</Text>
</View>
<Switch
value={settings?.autoRotate}
value={settings.autoRotate}
onValueChange={(value) => updateSettings({ autoRotate: value })}
/>
</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>
<View
pointerEvents={settings.autoRotate ? "none" : "auto"}
className={`
${
settings.autoRotate
? "opacity-50 pointer-events-none"
: "opacity-100"
}
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">Video orientation</Text>
<Text className="text-xs opacity-50">
Clicking a video will start it in fullscreen mode, instead of
inline.
Set the full screen video player orientation.
</Text>
</View>
<Switch
value={settings?.openFullScreenVideoPlayerByDefault}
onValueChange={(value) =>
updateSettings({ openFullScreenVideoPlayerByDefault: value })
}
/>
<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>
{ScreenOrientationEnum[settings.defaultVideoOrientation]}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Orientation</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.DEFAULT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.DEFAULT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.PORTRAIT_UP,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.PORTRAIT_UP
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="3"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_LEFT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="4"
onSelect={() => {
updateSettings({
defaultVideoOrientation:
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT,
});
}}
>
<DropdownMenu.ItemTitle>
{
ScreenOrientationEnum[
ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT
]
}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className="flex flex-row space-x-2 items-center justify-between bg-neutral-900 p-4">
@@ -110,7 +257,7 @@ export const SettingToggles: React.FC = () => {
</Text>
</View>
<Switch
value={settings?.openInVLC}
value={settings.openInVLC}
onValueChange={(value) => {
updateSettings({ openInVLC: value, forceDirectPlay: value });
}}
@@ -133,13 +280,13 @@ export const SettingToggles: React.FC = () => {
</TouchableOpacity>
</View>
<Switch
value={settings?.usePopularPlugin}
value={settings.usePopularPlugin}
onValueChange={(value) =>
updateSettings({ usePopularPlugin: value })
}
/>
</View>
{settings?.usePopularPlugin && (
{settings.usePopularPlugin && (
<View className="flex flex-col py-2 bg-neutral-900">
{mediaListCollections?.map((mlc) => (
<View
@@ -150,9 +297,7 @@ export const SettingToggles: React.FC = () => {
<Text className="font-semibold">{mlc.Name}</Text>
</View>
<Switch
value={settings?.mediaListCollectionIds?.includes(
mlc.Id!
)}
value={settings.mediaListCollectionIds?.includes(mlc.Id!)}
onValueChange={(value) => {
if (!settings.mediaListCollectionIds) {
updateSettings({
@@ -163,11 +308,11 @@ export const SettingToggles: React.FC = () => {
updateSettings({
mediaListCollectionIds:
settings?.mediaListCollectionIds.includes(mlc.Id!)
? settings?.mediaListCollectionIds.filter(
settings.mediaListCollectionIds.includes(mlc.Id!)
? settings.mediaListCollectionIds.filter(
(id) => id !== mlc.Id
)
: [...settings?.mediaListCollectionIds, mlc.Id!],
: [...settings.mediaListCollectionIds, mlc.Id!],
});
}}
/>
@@ -198,7 +343,7 @@ export const SettingToggles: React.FC = () => {
</Text>
</View>
<Switch
value={settings?.forceDirectPlay}
value={settings.forceDirectPlay}
onValueChange={(value) =>
updateSettings({ forceDirectPlay: value })
}
@@ -207,9 +352,9 @@ export const SettingToggles: React.FC = () => {
<View
className={`
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
${settings?.forceDirectPlay ? "opacity-50 select-none" : ""}
`}
flex flex-row items-center space-x-2 justify-between bg-neutral-900 p-4
${settings.forceDirectPlay ? "opacity-50 select-none" : ""}
`}
>
<View className="flex flex-col shrink">
<Text className="font-semibold">Device profile</Text>
@@ -221,7 +366,7 @@ export const SettingToggles: React.FC = () => {
<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>
<Text>{settings.deviceProfile}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -261,11 +406,12 @@ export const SettingToggles: React.FC = () => {
</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
`}
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>
@@ -276,7 +422,7 @@ export const SettingToggles: React.FC = () => {
<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>
<Text>{settings.searchEngine}</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
@@ -310,42 +456,179 @@ export const SettingToggles: React.FC = () => {
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
{settings?.searchEngine === "Marlin" && (
{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 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.endsWith("/")
? marlinUrl
: marlinUrl + "/",
});
}}
>
Save
</Button>
</View>
{settings.marlinServerUrl && (
<Text className="text-neutral-500 mt-2">
{settings?.marlinServerUrl}
Current: {settings.marlinServerUrl}
</Text>
</>
)}
</View>
)}
</View>
</View>
</View>
<View className="mt-4">
<Text className="text-lg font-bold mb-2">Downloads</Text>
<View className="flex flex-col rounded-xl overflow-hidden divide-y-2 divide-solid divide-neutral-800">
<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 method</Text>
<Text className="text-xs opacity-50">
Choose the download method to use. Optimized requires the
optimized server.
</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.downloadMethod === "remux"
? "Default"
: "Optimized"}
</Text>
</TouchableOpacity>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Methods</DropdownMenu.Label>
<DropdownMenu.Item
key="1"
onSelect={() => {
updateSettings({ downloadMethod: "remux" });
setProcesses([]);
}}
>
<DropdownMenu.ItemTitle>Default</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
<DropdownMenu.Item
key="2"
onSelect={() => {
updateSettings({ downloadMethod: "optimized" });
setProcesses([]);
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<DropdownMenu.ItemTitle>Optimized</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</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">Auto download</Text>
<Text className="text-xs opacity-50 shrink">
This will automatically download the media file when it's
finished optimizing on the server.
</Text>
</View>
<Switch
value={settings.autoDownload}
onValueChange={(value) => updateSettings({ autoDownload: value })}
/>
</View>
<View
pointerEvents={
settings.downloadMethod === "optimized" ? "auto" : "none"
}
className={`
${
settings.downloadMethod === "optimized"
? "opacity-100"
: "opacity-50"
}`}
>
<View className="flex flex-col bg-neutral-900 px-4 py-4">
<View className="flex flex-col shrink mb-2">
<View className="flex flex-row justify-between items-center">
<Text className="font-semibold">
Optimized versions server
</Text>
</View>
<Text className="text-xs opacity-50">
Set the URL for the optimized versions server for downloads.
</Text>
</View>
<View></View>
<View className="flex flex-col">
<Input
placeholder="Optimized versions server URL..."
value={optimizedVersionsServerUrl}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
onChangeText={(text) => setOptimizedVersionsServerUrl(text)}
/>
<Button
color="purple"
className="h-12 mt-2"
onPress={async () => {
updateSettings({
optimizedVersionsServerUrl:
optimizedVersionsServerUrl.length === 0
? null
: optimizedVersionsServerUrl.endsWith("/")
? optimizedVersionsServerUrl
: optimizedVersionsServerUrl + "/",
});
const res = await getStatistics({
url: settings?.optimizedVersionsServerUrl,
authHeader: api?.accessToken,
deviceId: await getOrSetDeviceId(),
});
if (res) {
toast.success("Connected");
} else toast.error("Could not connect");
}}
>
Save
</Button>
</View>
</View>
</View>
</View>
</View>
</View>
);
};

View File

@@ -15,7 +15,6 @@ const routes = [
"albums/[albumId]",
"artists/index",
"artists/[artistId]",
"collections/[collectionId]",
"items/page",
"series/[id]",
];

39
constants/Languages.ts Normal file
View File

@@ -0,0 +1,39 @@
import { DefaultLanguageOption } from "@/utils/atoms/settings";
export const LANGUAGES: DefaultLanguageOption[] = [
{ label: "English", value: "eng" },
{ label: "Spanish", value: "es" },
{ label: "Chinese (Mandarin)", value: "zh" },
{ label: "Hindi", value: "hi" },
{ label: "Arabic", value: "ar" },
{ label: "French", value: "fr" },
{ label: "Russian", value: "ru" },
{ label: "Portuguese", value: "pt" },
{ label: "Japanese", value: "ja" },
{ label: "German", value: "de" },
{ label: "Italian", value: "it" },
{ label: "Korean", value: "ko" },
{ label: "Turkish", value: "tr" },
{ label: "Dutch", value: "nl" },
{ label: "Polish", value: "pl" },
{ label: "Vietnamese", value: "vi" },
{ label: "Thai", value: "th" },
{ label: "Indonesian", value: "id" },
{ label: "Greek", value: "el" },
{ label: "Swedish", value: "sv" },
{ label: "Danish", value: "da" },
{ label: "Norwegian", value: "no" },
{ label: "Finnish", value: "fi" },
{ label: "Czech", value: "cs" },
{ label: "Hungarian", value: "hu" },
{ label: "Romanian", value: "ro" },
{ label: "Ukrainian", value: "uk" },
{ label: "Hebrew", value: "he" },
{ label: "Bengali", value: "bn" },
{ label: "Punjabi", value: "pa" },
{ label: "Tagalog", value: "tl" },
{ label: "Swahili", value: "sw" },
{ label: "Malay", value: "ms" },
{ label: "Persian", value: "fa" },
{ label: "Urdu", value: "ur" },
];

3
constants/Values.ts Normal file
View File

@@ -0,0 +1,3 @@
import { Platform } from "react-native";
export const TAB_HEIGHT = Platform.OS === "android" ? 58 : 74;

View File

@@ -1,6 +1,7 @@
{
"cli": {
"version": ">= 9.1.0"
"version": ">= 9.1.0",
"appVersionSource": "local"
},
"build": {
"development": {
@@ -21,13 +22,13 @@
}
},
"production": {
"channel": "0.12.0",
"channel": "0.16.0",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.12.0",
"channel": "0.16.0",
"android": {
"buildType": "apk",
"image": "latest"

View File

@@ -0,0 +1,76 @@
import { Api } from "@jellyfin/sdk";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api/items-api";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useQuery } from "@tanstack/react-query";
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
interface AdjacentEpisodesProps {
currentlyPlaying?: CurrentlyPlayingState | null;
}
export const useAdjacentEpisodes = ({
currentlyPlaying,
}: AdjacentEpisodesProps) => {
const [api] = useAtom(apiAtom);
const { data: previousItem } = useQuery({
queryKey: [
"previousItem",
currentlyPlaying?.item.ParentId,
currentlyPlaying?.item.IndexNumber,
],
queryFn: async (): Promise<BaseItemDto | null> => {
if (
!api ||
!currentlyPlaying?.item.ParentId ||
currentlyPlaying?.item.IndexNumber === undefined ||
currentlyPlaying?.item.IndexNumber === null ||
currentlyPlaying.item.IndexNumber - 2 < 0
) {
console.log("No previous item");
return null;
}
const res = await getItemsApi(api).getItems({
parentId: currentlyPlaying.item.ParentId!,
startIndex: currentlyPlaying.item.IndexNumber! - 2,
limit: 1,
});
return res.data.Items?.[0] || null;
},
enabled: currentlyPlaying?.item.Type === "Episode",
});
const { data: nextItem } = useQuery({
queryKey: [
"nextItem",
currentlyPlaying?.item.ParentId,
currentlyPlaying?.item.IndexNumber,
],
queryFn: async (): Promise<BaseItemDto | null> => {
if (
!api ||
!currentlyPlaying?.item.ParentId ||
currentlyPlaying?.item.IndexNumber === undefined ||
currentlyPlaying?.item.IndexNumber === null
) {
console.log("No next item");
return null;
}
const res = await getItemsApi(api).getItems({
parentId: currentlyPlaying.item.ParentId!,
startIndex: currentlyPlaying.item.IndexNumber!,
limit: 1,
});
return res.data.Items?.[0] || null;
},
enabled: currentlyPlaying?.item.Type === "Episode",
});
return { previousItem, nextItem };
};

View File

@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
runOnJS,
useAnimatedReaction,
useSharedValue,
} from "react-native-reanimated";
export const useControlsVisibility = (timeout: number = 3000) => {
const opacity = useSharedValue(1);
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const showControls = useCallback(() => {
opacity.value = 1;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
hideControlsTimerRef.current = setTimeout(() => {
opacity.value = 0;
}, timeout);
}, [timeout]);
const hideControls = useCallback(() => {
opacity.value = 0;
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hideControlsTimerRef.current) {
clearTimeout(hideControlsTimerRef.current);
}
};
}, []);
return { opacity, showControls, hideControls };
};

72
hooks/useCreditSkipper.ts Normal file
View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
interface CreditTimestamps {
Introduction: {
Start: number;
End: number;
Valid: boolean;
};
Credits: {
Start: number;
End: number;
Valid: boolean;
};
}
export const useCreditSkipper = (
itemId: string | undefined,
currentTime: number,
videoRef: React.RefObject<any>
) => {
const [api] = useAtom(apiAtom);
const [showSkipCreditButton, setShowSkipCreditButton] = useState(false);
const { data: creditTimestamps } = useQuery<CreditTimestamps | null>({
queryKey: ["creditTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
console.log("No item id");
return null;
}
const res = await api?.axiosInstance.get(
`${api.basePath}/Episode/${itemId}/Timestamps`,
{
headers: getAuthHeaders(api),
}
);
if (res?.status !== 200) {
return null;
}
return res?.data;
},
enabled: !!itemId,
});
useEffect(() => {
if (creditTimestamps) {
setShowSkipCreditButton(
currentTime > creditTimestamps.Credits.Start &&
currentTime < creditTimestamps.Credits.End
);
}
}, [creditTimestamps, currentTime]);
const skipCredit = useCallback(() => {
if (!creditTimestamps || !videoRef.current) return;
try {
videoRef.current.seek(creditTimestamps.Credits.End);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
}, [creditTimestamps, videoRef]);
return { showSkipCreditButton, skipCredit };
};

View File

@@ -1,117 +0,0 @@
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);
}
}

View File

@@ -0,0 +1,55 @@
// hooks/useFileOpener.ts
import { usePlayback } from "@/providers/PlaybackProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import { useCallback } from "react";
export const useFileOpener = () => {
const router = useRouter();
const { startDownloadedFilePlayback } = usePlayback();
const openFile = useCallback(
async (item: BaseItemDto) => {
const directory = FileSystem.documentDirectory;
if (!directory) {
throw new Error("Document directory is not available");
}
if (!item.Id) {
throw new Error("Item ID is not available");
}
try {
const files = await FileSystem.readDirectoryAsync(directory);
for (let f of files) {
console.log(f);
}
const path = item.Id!;
const matchingFile = files.find((file) => file.startsWith(path));
if (!matchingFile) {
throw new Error(`No file found for item ${path}`);
}
const url = `${directory}${matchingFile}`;
console.log("Opening " + url);
startDownloadedFilePlayback({
item,
url,
});
router.push("/play");
} catch (error) {
console.error("Error opening file:", error);
// Handle the error appropriately, e.g., show an error message to the user
}
},
[startDownloadedFilePlayback]
);
return { openFile };
};

View File

@@ -1,85 +0,0 @@
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"] });
queryClient.invalidateQueries({ queryKey: ["downloaded"] });
} 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 [];
}
}

View File

@@ -1,46 +1,95 @@
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { apiAtom } from "@/providers/JellyfinProvider";
import {
adjustToNearBlack,
calculateTextColor,
isCloseToBlack,
itemThemeColorAtom,
} from "@/utils/atoms/primaryColor";
import { getItemImage } from "@/utils/getItemImage";
import { storage } from "@/utils/mmkv";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { getColors } from "react-native-image-colors";
export const useImageColors = (
uri: string | undefined | null,
disabled = false
) => {
/**
* Custom hook to extract and manage image colors for a given item.
*
* @param item - The BaseItemDto object representing the item.
* @param disabled - A boolean flag to disable color extraction.
*
*/
export const useImageColors = (item?: BaseItemDto | null, disabled = false) => {
const [api] = useAtom(apiAtom);
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
const source = useMemo(() => {
if (!api || !item) return;
return getItemImage({
item,
api,
variant: "Primary",
quality: 80,
width: 300,
});
}, [api, item]);
useEffect(() => {
if (disabled) return;
if (uri) {
getColors(uri, {
if (source?.uri) {
// Check if colors are already cached in storage
const _primary = storage.getString(`${source.uri}-primary`);
const _text = storage.getString(`${source.uri}-text`);
// If colors are cached, use them and exit
if (_primary && _text) {
console.info("[useImageColors] Using cached colors for performance.");
setPrimaryColor({
primary: _primary,
text: _text,
});
return;
}
// Extract colors from the image
getColors(source.uri, {
fallback: "#fff",
cache: true,
key: uri,
key: source.uri,
})
.then((colors) => {
let primary: string = "#fff";
let average: string = "#fff";
let secondary: string = "#fff";
let text: string = "#000";
// Select the appropriate color based on the platform
if (colors.platform === "android") {
primary = colors.dominant;
average = colors.average;
secondary = colors.muted;
} else if (colors.platform === "ios") {
primary = colors.primary;
secondary = colors.detail;
average = colors.background;
}
// Adjust the primary color if it's too close to black
if (primary && isCloseToBlack(primary)) {
primary = adjustToNearBlack(primary);
}
// Calculate the text color based on the primary color
if (primary) text = calculateTextColor(primary);
setPrimaryColor({
primary,
secondary,
average,
text,
});
// Cache the colors in storage
if (source.uri && primary) {
storage.set(`${source.uri}-primary`, primary);
storage.set(`${source.uri}-text`, text);
}
})
.catch((error) => {
console.error("Error getting colors", error);
});
}
}, [uri, setPrimaryColor, disabled]);
}, [source?.uri, setPrimaryColor, disabled]);
};

89
hooks/useImageStorage.ts Normal file
View File

@@ -0,0 +1,89 @@
import { useState, useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as FileSystem from "expo-file-system";
import { storage } from "@/utils/mmkv";
const useImageStorage = () => {
const saveBase64Image = useCallback(async (base64: string, key: string) => {
try {
// Save the base64 string to AsyncStorage
storage.set(key, base64);
console.log("Image saved successfully");
} catch (error) {
console.error("Error saving image:", error);
throw error;
}
}, []);
const image2Base64 = useCallback(async (url?: string | null) => {
if (!url) return null;
let blob: Blob;
try {
// Fetch the data from the URL
const response = await fetch(url);
blob = await response.blob();
} catch (error) {
console.warn("Error fetching image:", error);
return null;
}
// Create a FileReader instance
const reader = new FileReader();
// Convert blob to base64
return new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
if (typeof reader.result === "string") {
// Extract the base64 string (remove the data URL prefix)
const base64 = reader.result.split(",")[1];
resolve(base64);
} else {
reject(new Error("Failed to convert image to base64"));
}
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}, []);
const saveImage = useCallback(
async (key?: string | null, imageUrl?: string | null) => {
if (!imageUrl || !key) {
console.warn("Invalid image URL or key");
return;
}
try {
const base64Image = await image2Base64(imageUrl);
if (!base64Image || base64Image.length === 0) {
console.warn("Failed to convert image to base64");
return;
}
saveBase64Image(base64Image, key);
} catch (error) {
console.warn("Error saving image:", error);
}
},
[]
);
const loadImage = useCallback(async (key: string) => {
try {
// Retrieve the base64 string from AsyncStorage
const base64Image = storage.getString(key);
if (base64Image !== null) {
// Set the loaded image state
return `data:image/jpeg;base64,${base64Image}`;
}
return null;
} catch (error) {
console.error("Error loading image:", error);
throw error;
}
}, []);
return { saveImage, loadImage, saveBase64Image, image2Base64 };
};
export default useImageStorage;

68
hooks/useIntroSkipper.ts Normal file
View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { writeToLog } from "@/utils/log";
interface IntroTimestamps {
EpisodeId: string;
HideSkipPromptAt: number;
IntroEnd: number;
IntroStart: number;
ShowSkipPromptAt: number;
Valid: boolean;
}
export const useIntroSkipper = (
itemId: string | undefined,
currentTime: number,
videoRef: React.RefObject<any>
) => {
const [api] = useAtom(apiAtom);
const [showSkipButton, setShowSkipButton] = useState(false);
const { data: introTimestamps } = useQuery<IntroTimestamps | null>({
queryKey: ["introTimestamps", itemId],
queryFn: async () => {
if (!itemId) {
console.log("No item id");
return null;
}
const res = await api?.axiosInstance.get(
`${api.basePath}/Episode/${itemId}/IntroTimestamps`,
{
headers: getAuthHeaders(api),
}
);
if (res?.status !== 200) {
return null;
}
return res?.data;
},
enabled: !!itemId,
});
useEffect(() => {
if (introTimestamps) {
setShowSkipButton(
currentTime > introTimestamps.ShowSkipPromptAt &&
currentTime < introTimestamps.HideSkipPromptAt
);
}
}, [introTimestamps, currentTime]);
const skipIntro = useCallback(() => {
if (!introTimestamps || !videoRef.current) return;
try {
videoRef.current.seek(introTimestamps.IntroEnd);
} catch (error) {
writeToLog("ERROR", "Error skipping intro", error);
}
}, [introTimestamps, videoRef]);
return { showSkipButton, skipIntro };
};

View File

@@ -0,0 +1,27 @@
// hooks/useNavigationBarVisibility.ts
import { useEffect } from "react";
import { Platform } from "react-native";
import * as NavigationBar from "expo-navigation-bar";
export const useNavigationBarVisibility = (isPlaying: boolean | null) => {
useEffect(() => {
const handleVisibility = async () => {
if (Platform.OS === "android") {
if (isPlaying) {
await NavigationBar.setVisibilityAsync("hidden");
} else {
await NavigationBar.setVisibilityAsync("visible");
}
}
};
handleVisibility();
return () => {
if (Platform.OS === "android") {
NavigationBar.setVisibilityAsync("visible");
}
};
}, [isPlaying]);
};

View File

@@ -4,9 +4,12 @@ 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";
import { toast } from "sonner-native";
import { useDownload } from "@/providers/DownloadProvider";
import { useRouter } from "expo-router";
import { JobStatus } from "@/utils/optimize-server";
/**
* Custom hook for remuxing HLS to MP4 using FFmpeg.
@@ -16,8 +19,9 @@ import { useQueryClient } from "@tanstack/react-query";
* @returns An object with remuxing-related functions
*/
export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const [_, setProgress] = useAtom(runningProcesses);
const queryClient = useQueryClient();
const { saveDownloadedItemInfo, setProcesses } = useDownload();
const router = useRouter();
if (!item.Id || !item.Name) {
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
@@ -28,6 +32,18 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const startRemuxing = useCallback(
async (url: string) => {
if (!item.Id) throw new Error("Item must have an Id");
toast.success(`Download started for ${item.Name}`, {
action: {
label: "Go to download",
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
});
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(
@@ -36,7 +52,20 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
);
try {
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
setProcesses((prev) => [
...prev,
{
id: "",
deviceId: "",
inputUrl: "",
item,
itemId: item.Id,
outputPath: "",
progress: 0,
status: "downloading",
timestamp: new Date(),
} as JobStatus,
]);
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
@@ -51,11 +80,19 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
setProgress((prev) =>
prev?.item.Id === item.Id!
? { ...prev, progress: percentage, speed }
: prev
);
if (!item.Id) throw new Error("Item is undefined");
setProcesses((prev) => {
return prev.map((process) => {
if (process.itemId === item.Id) {
return {
...process,
progress: percentage,
speed: Math.max(speed, 0),
};
}
return process;
});
});
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
@@ -65,11 +102,16 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
if (!item) throw new Error("Item is undefined");
await saveDownloadedItemInfo(item);
toast.success("Download completed");
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`
);
await queryClient.invalidateQueries({
queryKey: ["downloadedItems"],
});
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
@@ -85,63 +127,35 @@ export const useRemuxHlsToMp4 = (item: BaseItemDto) => {
resolve();
}
setProgress(null);
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
} 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);
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
throw error; // Re-throw the error to propagate it to the caller
}
},
[output, item, setProgress]
[output, item]
);
const cancelRemuxing = useCallback(() => {
FFmpegKit.cancel();
setProgress(null);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`
);
}, [item.Name, setProgress]);
setProcesses((prev) => {
return prev.filter((process) => process.itemId !== item.Id);
});
}, [item.Name]);
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}`
);
}
}

108
hooks/useTrickplay.ts Normal file
View File

@@ -0,0 +1,108 @@
// hooks/useTrickplay.ts
import { useState, useCallback, useMemo, useRef } from "react";
import { Api } from "@jellyfin/sdk";
import { SharedValue } from "react-native-reanimated";
import { CurrentlyPlayingState } from "@/providers/PlaybackProvider";
import { useAtom } from "jotai";
import { apiAtom } from "@/providers/JellyfinProvider";
interface TrickplayData {
Interval?: number;
TileWidth?: number;
TileHeight?: number;
Height?: number;
Width?: number;
ThumbnailCount?: number;
}
interface TrickplayInfo {
resolution: string;
aspectRatio: number;
data: TrickplayData;
}
interface TrickplayUrl {
x: number;
y: number;
url: string;
}
export const useTrickplay = (
currentlyPlaying?: CurrentlyPlayingState | null
) => {
const [api] = useAtom(apiAtom);
const [trickPlayUrl, setTrickPlayUrl] = useState<TrickplayUrl | null>(null);
const lastCalculationTime = useRef(0);
const throttleDelay = 200; // 200ms throttle
const trickplayInfo = useMemo(() => {
if (!currentlyPlaying?.item.Id || !currentlyPlaying?.item.Trickplay) {
return null;
}
const mediaSourceId = currentlyPlaying.item.Id;
const trickplayData = currentlyPlaying.item.Trickplay[mediaSourceId];
if (!trickplayData) {
return null;
}
// Get the first available resolution
const firstResolution = Object.keys(trickplayData)[0];
return firstResolution
? {
resolution: firstResolution,
aspectRatio:
trickplayData[firstResolution].Width! /
trickplayData[firstResolution].Height!,
data: trickplayData[firstResolution],
}
: null;
}, [currentlyPlaying]);
const calculateTrickplayUrl = useCallback(
(progress: number) => {
const now = Date.now();
if (now - lastCalculationTime.current < throttleDelay) {
return null;
}
lastCalculationTime.current = now;
if (!trickplayInfo || !api || !currentlyPlaying?.item.Id) {
return null;
}
const { data, resolution } = trickplayInfo;
const { Interval, TileWidth, TileHeight } = data;
if (!Interval || !TileWidth || !TileHeight || !resolution) {
throw new Error("Invalid trickplay data");
}
const currentSecond = Math.max(0, Math.floor(progress / 10000000));
const cols = TileWidth;
const rows = TileHeight;
const imagesPerTile = cols * rows;
const imageIndex = Math.floor(currentSecond / (Interval / 1000));
const tileIndex = Math.floor(imageIndex / imagesPerTile);
const positionInTile = imageIndex % imagesPerTile;
const rowInTile = Math.floor(positionInTile / cols);
const colInTile = positionInTile % cols;
const newTrickPlayUrl = {
x: rowInTile,
y: colInTile,
url: `${api.basePath}/Videos/${currentlyPlaying.item.Id}/Trickplay/${resolution}/${tileIndex}.jpg?api_key=${api.accessToken}`,
};
setTrickPlayUrl(newTrickPlayUrl);
return newTrickPlayUrl;
},
[trickplayInfo, currentlyPlaying, api]
);
return { trickPlayUrl, calculateTrickplayUrl, trickplayInfo };
};

View File

@@ -17,65 +17,71 @@
"dependencies": {
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.2",
"@expo/vector-icons": "^14.0.3",
"@gorhom/bottom-sheet": "^4",
"@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.1",
"@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-menu/menu": "^1.1.3",
"@react-navigation/native": "^6.0.2",
"@shopify/flash-list": "1.6.4",
"@tanstack/react-query": "^5.54.1",
"@types/lodash": "^4.17.7",
"@tanstack/react-query": "^5.56.2",
"@types/lodash": "^4.17.9",
"@types/uuid": "^10.0.0",
"axios": "^1.7.7",
"expo": "~51.0.32",
"expo": "~51.0.36",
"expo-background-fetch": "~12.0.1",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.26",
"expo-dev-client": "~4.0.27",
"expo-device": "~6.0.2",
"expo-font": "~12.0.10",
"expo-haptics": "~13.0.1",
"expo-image": "~1.12.15",
"expo-image": "~1.13.0",
"expo-keep-awake": "~13.0.2",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
"expo-network": "~6.0.1",
"expo-notifications": "~0.28.18",
"expo-router": "~3.5.23",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.5",
"expo-splash-screen": "~0.27.6",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.24",
"expo-task-manager": "~11.8.2",
"expo-updates": "~0.25.26",
"expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.3",
"jotai": "^2.10.0",
"lodash": "^4.17.21",
"nativewind": "^2.0.11",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.74.5",
"react-native": "~0.75.0",
"react-native-awesome-slider": "^2.5.3",
"react-native-circular-progress": "^1.4.0",
"react-native-compressor": "^1.8.25",
"react-native-gesture-handler": "~2.16.1",
"react-native-gesture-handler": "~2.18.1",
"react-native-get-random-values": "^1.11.0",
"react-native-google-cast": "^4.8.2",
"react-native-google-cast": "^4.8.3",
"react-native-image-colors": "^2.4.0",
"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-mmkv": "^2.12.2",
"react-native-reanimated": "~3.15.0",
"react-native-reanimated-carousel": "4.0.0-canary.15",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-screens": "~3.34.0",
"react-native-svg": "15.2.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-uuid": "^2.0.2",
"react-native-video": "^6.5.0",
"react-native-video": "^6.6.3",
"react-native-web": "~0.19.10",
"sonner-native": "^0.14.2",
"tailwindcss": "3.3.2",
"use-debounce": "^10.0.3",
"uuid": "^10.0.0",
@@ -92,5 +98,15 @@
"react-test-renderer": "18.2.0",
"typescript": "~5.3.3"
},
"private": true
"private": true,
"expo": {
"install": {
"exclude": [
"react-native@~0.74.0",
"react-native-reanimated@~3.10.0",
"react-native-gesture-handler@~2.16.1",
"react-native-screens@~3.31.1"
]
}
}
}

View File

@@ -0,0 +1,48 @@
const { withAppDelegate } = require("@expo/config-plugins");
function withRNBackgroundDownloader(expoConfig) {
return withAppDelegate(expoConfig, async (appDelegateConfig) => {
const { modResults: appDelegate } = appDelegateConfig;
const appDelegateLines = appDelegate.contents.split("\n");
// Define the code to be added to AppDelegate.mm
const backgroundDownloaderImport =
"#import <RNBackgroundDownloader.h> // Required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js";
const backgroundDownloaderDelegate = `\n// Delegate method required by react-native-background-downloader. Generated by expoPlugins/withRNBackgroundDownloader.js
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler
{
[RNBackgroundDownloader setCompletionHandlerWithIdentifier:identifier completionHandler:completionHandler];
}`;
// Find the index of the AppDelegate import statement
const importIndex = appDelegateLines.findIndex((line) =>
/^#import "AppDelegate.h"/.test(line)
);
// Find the index of the last line before the @end statement
const endStatementIndex = appDelegateLines.findIndex((line) =>
/@end/.test(line)
);
// Insert the import statement if it's not already present
if (!appDelegate.contents.includes(backgroundDownloaderImport)) {
appDelegateLines.splice(importIndex + 1, 0, backgroundDownloaderImport);
}
// Insert the delegate method above the @end statement
if (!appDelegate.contents.includes(backgroundDownloaderDelegate)) {
appDelegateLines.splice(
endStatementIndex,
0,
backgroundDownloaderDelegate
);
}
// Update the contents of the AppDelegate file
appDelegate.contents = appDelegateLines.join("\n");
return appDelegateConfig;
});
}
module.exports = withRNBackgroundDownloader;

View File

@@ -0,0 +1,560 @@
import { useSettings } from "@/utils/atoms/settings";
import { getOrSetDeviceId } from "@/utils/device";
import { writeToLog } from "@/utils/log";
import {
cancelAllJobs,
cancelJobById,
getAllJobsByDeviceId,
JobStatus,
} from "@/utils/optimize-server";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
checkForExistingDownloads,
completeHandler,
download,
setConfig,
} from "@kesha-antonov/react-native-background-downloader";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
focusManager,
QueryClient,
QueryClientProvider,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios";
import * as FileSystem from "expo-file-system";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { AppState, AppStateStatus } from "react-native";
import { toast } from "sonner-native";
import { apiAtom } from "./JellyfinProvider";
import * as Notifications from "expo-notifications";
import { getItemImage } from "@/utils/getItemImage";
import useImageStorage from "@/hooks/useImageStorage";
function onAppStateChange(status: AppStateStatus) {
focusManager.setFocused(status === "active");
}
const DownloadContext = createContext<ReturnType<
typeof useDownloadProvider
> | null>(null);
function useDownloadProvider() {
const queryClient = useQueryClient();
const [settings] = useSettings();
const router = useRouter();
const [api] = useAtom(apiAtom);
const { loadImage, saveImage, image2Base64, saveBase64Image } =
useImageStorage();
const [processes, setProcesses] = useState<JobStatus[]>([]);
const authHeader = useMemo(() => {
return api?.accessToken;
}, [api]);
const { data: downloadedFiles, refetch } = useQuery({
queryKey: ["downloadedItems"],
queryFn: getAllDownloadedItems,
staleTime: 0,
refetchOnMount: true,
refetchOnReconnect: true,
refetchOnWindowFocus: true,
});
useEffect(() => {
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, []);
useQuery({
queryKey: ["jobs"],
queryFn: async () => {
const deviceId = await getOrSetDeviceId();
const url = settings?.optimizedVersionsServerUrl;
if (
settings?.downloadMethod !== "optimized" ||
!url ||
!deviceId ||
!authHeader
)
return [];
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader,
url,
});
// Local downloading processes that are still valid
const downloadingProcesses = processes
.filter((p) => p.status === "downloading")
.filter((p) => jobs.some((j) => j.id === p.id));
const updatedProcesses = jobs.filter(
(j) => !downloadingProcesses.some((p) => p.id === j.id)
);
setProcesses([...updatedProcesses, ...downloadingProcesses]);
// Go though new jobs and compare them to old jobs
// if new job is now completed, start download.
for (let job of jobs) {
const process = processes.find((p) => p.id === job.id);
if (
process &&
process.status === "optimizing" &&
job.status === "completed"
) {
if (settings.autoDownload) {
startDownload(job);
} else {
toast.info(`${job.item.Name} is ready to be downloaded`, {
action: {
label: "Go to downloads",
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
});
Notifications.scheduleNotificationAsync({
content: {
title: job.item.Name,
body: `${job.item.Name} is ready to be downloaded`,
data: {
url: `/downloads`,
},
},
trigger: null,
});
}
}
}
return jobs;
},
staleTime: 0,
refetchInterval: 2000,
enabled: settings?.downloadMethod === "optimized",
});
useEffect(() => {
const checkIfShouldStartDownload = async () => {
if (processes.length === 0) return;
await checkForExistingDownloads();
};
checkIfShouldStartDownload();
}, [settings, processes]);
const removeProcess = useCallback(
async (id: string) => {
const deviceId = await getOrSetDeviceId();
if (!deviceId || !authHeader || !settings?.optimizedVersionsServerUrl)
return;
try {
await cancelJobById({
authHeader,
id,
url: settings?.optimizedVersionsServerUrl,
});
} catch (error) {
console.log(error);
}
},
[settings?.optimizedVersionsServerUrl, authHeader]
);
const startDownload = useCallback(
async (process: JobStatus) => {
if (!process?.item.Id || !authHeader) throw new Error("No item id");
console.log("[0] Setting process to downloading");
setProcesses((prev) =>
prev.map((p) =>
p.id === process.id
? {
...p,
speed: undefined,
status: "downloading",
progress: 0,
}
: p
)
);
setConfig({
isLogsEnabled: true,
progressInterval: 500,
headers: {
Authorization: authHeader,
},
});
toast.info(`Download started for ${process.item.Name}`, {
action: {
label: "Go to downloads",
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
});
const baseDirectory = FileSystem.documentDirectory;
download({
id: process.id,
url: settings?.optimizedVersionsServerUrl + "download/" + process.id,
destination: `${baseDirectory}/${process.item.Id}.mp4`,
})
.begin(() => {
setProcesses((prev) =>
prev.map((p) =>
p.id === process.id
? {
...p,
speed: undefined,
status: "downloading",
progress: 0,
}
: p
)
);
})
.progress((data) => {
const percent = (data.bytesDownloaded / data.bytesTotal) * 100;
console.log("Download progress:", percent);
setProcesses((prev) =>
prev.map((p) =>
p.id === process.id
? {
...p,
speed: undefined,
status: "downloading",
progress: percent,
}
: p
)
);
})
.done(async () => {
await saveDownloadedItemInfo(process.item);
toast.success(`Download completed for ${process.item.Name}`, {
duration: 3000,
action: {
label: "Go to downloads",
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
});
setTimeout(() => {
completeHandler(process.id);
removeProcess(process.id);
}, 1000);
})
.error(async (error) => {
removeProcess(process.id);
completeHandler(process.id);
let errorMsg = "";
if (error.errorCode === 1000) {
errorMsg = "No space left";
}
if (error.errorCode === 404) {
errorMsg = "File not found on server";
}
toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`);
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
error,
processDetails: {
id: process.id,
itemName: process.item.Name,
itemId: process.item.Id,
},
});
console.error("Error details:", {
errorCode: error.errorCode,
});
});
},
[queryClient, settings?.optimizedVersionsServerUrl, authHeader]
);
const startBackgroundDownload = useCallback(
async (url: string, item: BaseItemDto, fileExtension: string) => {
if (!api || !item.Id || !authHeader)
throw new Error("startBackgroundDownload ~ Missing required params");
try {
const deviceId = await getOrSetDeviceId();
const itemImage = getItemImage({
item,
api,
variant: "Primary",
quality: 90,
width: 500,
});
await saveImage(item.Id, itemImage?.uri);
const response = await axios.post(
settings?.optimizedVersionsServerUrl + "optimize-version",
{
url,
fileExtension,
deviceId,
itemId: item.Id,
item,
},
{
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
},
}
);
if (response.status !== 201) {
throw new Error("Failed to start optimization job");
}
toast.success(`Queued ${item.Name} for optimization`, {
action: {
label: "Go to download",
onClick: () => {
router.push("/downloads");
toast.dismiss();
},
},
});
} catch (error) {
console.error("Error in startBackgroundDownload:", error);
if (axios.isAxiosError(error)) {
console.error("Axios error details:", {
message: error.message,
response: error.response?.data,
status: error.response?.status,
headers: error.response?.headers,
});
toast.error(
`Failed to start download for ${item.Name}: ${error.message}`
);
if (error.response) {
toast.error(
`Server responded with status ${error.response.status}`
);
} else if (error.request) {
toast.error("No response received from server");
} else {
toast.error("Error setting up the request");
}
} else {
console.error("Non-Axios error:", error);
toast.error(
`Failed to start download for ${item.Name}: Unexpected error`
);
}
}
},
[settings?.optimizedVersionsServerUrl, authHeader]
);
const deleteAllFiles = async (): Promise<void> => {
try {
await deleteLocalFiles();
await removeDownloadedItemsFromStorage();
await cancelAllServerJobs();
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
toast.success("All files, folders, and jobs deleted successfully");
} catch (error) {
console.error("Failed to delete all files, folders, and jobs:", error);
toast.error("An error occurred while deleting files and jobs");
}
};
const deleteLocalFiles = async (): Promise<void> => {
const baseDirectory = FileSystem.documentDirectory;
if (!baseDirectory) {
throw new Error("Base directory not found");
}
const dirContents = await FileSystem.readDirectoryAsync(baseDirectory);
for (const item of dirContents) {
const itemPath = `${baseDirectory}${item}`;
const itemInfo = await FileSystem.getInfoAsync(itemPath);
if (itemInfo.exists) {
if (itemInfo.isDirectory) {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
} else {
await FileSystem.deleteAsync(itemPath, { idempotent: true });
}
}
}
};
const removeDownloadedItemsFromStorage = async (): Promise<void> => {
try {
await AsyncStorage.removeItem("downloadedItems");
} catch (error) {
console.error(
"Failed to remove downloadedItems from AsyncStorage:",
error
);
throw error;
}
};
const cancelAllServerJobs = async (): Promise<void> => {
if (!authHeader) {
throw new Error("No auth header available");
}
if (!settings?.optimizedVersionsServerUrl) {
throw new Error("No server URL configured");
}
const deviceId = await getOrSetDeviceId();
if (!deviceId) {
throw new Error("Failed to get device ID");
}
try {
await cancelAllJobs({
authHeader,
url: settings.optimizedVersionsServerUrl,
deviceId,
});
} catch (error) {
console.error("Failed to cancel all server jobs:", error);
throw error;
}
};
const deleteFile = async (id: string): Promise<void> => {
if (!id) {
console.error("Invalid file ID");
return;
}
try {
const directory = FileSystem.documentDirectory;
if (!directory) {
console.error("Document directory not found");
return;
}
const dirContents = await FileSystem.readDirectoryAsync(directory);
for (const item of dirContents) {
const itemNameWithoutExtension = item.split(".")[0];
if (itemNameWithoutExtension === id) {
const filePath = `${directory}${item}`;
await FileSystem.deleteAsync(filePath, { idempotent: true });
console.log(`Successfully deleted file: ${item}`);
break;
}
}
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
if (downloadedItems) {
let items = JSON.parse(downloadedItems);
items = items.filter((item: any) => item.Id !== id);
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
}
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
console.log(
`Successfully deleted file and AsyncStorage entry for ID ${id}`
);
} catch (error) {
console.error(
`Failed to delete file and AsyncStorage entry for ID ${id}:`,
error
);
}
};
async function getAllDownloadedItems(): Promise<BaseItemDto[]> {
try {
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
if (downloadedItems) {
return JSON.parse(downloadedItems) as BaseItemDto[];
} else {
return [];
}
} catch (error) {
console.error("Failed to retrieve downloaded items:", error);
return [];
}
}
async function saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {
items[existingItemIndex] = item;
} else {
items.push(item);
}
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
refetch();
} catch (error) {
console.error("Failed to save downloaded item information:", error);
}
}
return {
processes,
startBackgroundDownload,
downloadedFiles,
deleteAllFiles,
deleteFile,
saveDownloadedItemInfo,
removeProcess,
setProcesses,
startDownload,
};
}
export function DownloadProvider({ children }: { children: React.ReactNode }) {
const downloadProviderValue = useDownloadProvider();
const queryClient = new QueryClient();
return (
<DownloadContext.Provider value={downloadProviderValue}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</DownloadContext.Provider>
);
}
export function useDownload() {
const context = useContext(DownloadContext);
if (context === null) {
throw new Error("useDownload must be used within a DownloadProvider");
}
return context;
}

View File

@@ -40,17 +40,6 @@ const JellyfinContext = createContext<JellyfinContextValue | undefined>(
undefined
);
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 JellyfinProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
@@ -63,7 +52,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.12.0" },
clientInfo: { name: "Streamyfin", version: "0.16.0" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
})
);
@@ -97,7 +86,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
return {
authorization: `MediaBrowser Client="Streamyfin", Device=${
Platform.OS === "android" ? "Android" : "iOS"
}, DeviceId="${deviceId}", Version="0.12.0"`,
}, DeviceId="${deviceId}", Version="0.16.0"`,
};
}, [deviceId]);
@@ -269,10 +258,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
],
queryFn: async () => {
try {
const token = await AsyncStorage.getItem("token");
const serverUrl = await AsyncStorage.getItem("serverUrl");
const token = await getTokenFromStoraage();
const serverUrl = await getServerUrlFromStorage();
const user = JSON.parse(
(await AsyncStorage.getItem("user")) as string
(await getUserFromStorage()) as string
) as UserDto;
if (serverUrl && token && user.Id && jellyfin) {
@@ -331,3 +320,26 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
}
}, [user, segments, loading]);
}
export async function getTokenFromStoraage() {
return await AsyncStorage.getItem("token");
}
export async function getUserFromStorage() {
return await AsyncStorage.getItem("user");
}
export async function getServerUrlFromStorage() {
return await AsyncStorage.getItem("serverUrl");
}
export async function getOrSetDeviceId() {
let deviceId = await AsyncStorage.getItem("deviceId");
if (!deviceId) {
deviceId = uuid.v4() as string;
await AsyncStorage.setItem("deviceId", deviceId);
}
return deviceId;
}

View File

@@ -11,6 +11,7 @@ import React, {
import { useSettings } from "@/utils/atoms/settings";
import { getDeviceId } from "@/utils/device";
import { SubtitleTrack } from "@/utils/hls/parseM3U8ForSubtitles";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { postCapabilities } from "@/utils/jellyfin/session/capabilities";
@@ -18,23 +19,16 @@ import {
BaseItemDto,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getMediaInfoApi, getSyncPlayApi } from "@jellyfin/sdk/lib/utils/api";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import * as Linking from "expo-linking";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { debounce, isBuffer } from "lodash";
import { debounce } from "lodash";
import { Alert } from "react-native";
import { OnProgressData, type VideoRef } from "react-native-video";
import { apiAtom, userAtom } from "./JellyfinProvider";
import {
GroupData,
GroupJoinedData,
PlayQueueData,
StateUpdateData,
} from "@/types/syncplay";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
type CurrentlyPlayingState = {
export type CurrentlyPlayingState = {
url: string;
item: BaseItemDto;
};
@@ -43,8 +37,6 @@ interface PlaybackContextType {
sessionData: PlaybackInfoResponse | null | undefined;
currentlyPlaying: CurrentlyPlayingState | null;
videoRef: React.MutableRefObject<VideoRef | null>;
onBuffer: (isBuffering: boolean) => void;
onReady: () => void;
isPlaying: boolean;
isFullscreen: boolean;
progressTicks: number | null;
@@ -55,6 +47,8 @@ interface PlaybackContextType {
dismissFullscreenPlayer: () => void;
setIsFullscreen: (isFullscreen: boolean) => void;
setIsPlaying: (isPlaying: boolean) => void;
isBuffering: boolean;
setIsBuffering: (val: boolean) => void;
onProgress: (data: OnProgressData) => void;
setVolume: (volume: number) => void;
setCurrentlyPlayingState: (
@@ -63,6 +57,7 @@ interface PlaybackContextType {
startDownloadedFilePlayback: (
currentlyPlaying: CurrentlyPlayingState | null
) => void;
subtitles: SubtitleTrack[];
}
const PlaybackContext = createContext<PlaybackContextType | null>(null);
@@ -73,6 +68,8 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const router = useRouter();
const videoRef = useRef<VideoRef | null>(null);
const [settings] = useSettings();
@@ -80,11 +77,12 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
const previousVolume = useRef<number | null>(null);
const [isPlaying, _setIsPlaying] = useState<boolean>(false);
const [isBuffering, setIsBuffering] = useState<boolean>(false);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [progressTicks, setProgressTicks] = useState<number | null>(0);
const [volume, _setVolume] = useState<number | null>(null);
const [session, setSession] = useState<PlaybackInfoResponse | null>(null);
const [syncplayGroup, setSyncplayGroup] = useState<GroupData | null>(null);
const [subtitles, setSubtitles] = useState<SubtitleTrack[]>([]);
const [currentlyPlaying, setCurrentlyPlaying] =
useState<CurrentlyPlayingState | null>(null);
@@ -116,17 +114,12 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
setCurrentlyPlaying(state);
setIsPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault) {
setTimeout(() => {
presentFullscreenPlayer();
}, 300);
}
},
[settings?.openFullScreenVideoPlayerByDefault]
[]
);
const setCurrentlyPlayingState = useCallback(
async (state: CurrentlyPlayingState | null, paused = false) => {
async (state: CurrentlyPlayingState | null) => {
try {
if (state?.item.Id && user?.Id) {
const vlcLink = "vlc://" + state?.url;
@@ -144,22 +137,12 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
api,
itemId: state.item.Id,
sessionId: res.data.PlaySessionId,
deviceProfile: settings?.deviceProfile,
});
setSession(res.data);
setCurrentlyPlaying(state);
if (paused === true) {
pauseVideo();
} else {
playVideo();
}
if (settings?.openFullScreenVideoPlayerByDefault) {
setTimeout(() => {
presentFullscreenPlayer();
}, 300);
}
setIsPlaying(true);
} else {
setCurrentlyPlaying(null);
setIsFullscreen(false);
@@ -177,11 +160,6 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
onPress: () => {
setCurrentlyPlaying(state);
setIsPlaying(true);
if (settings?.openFullScreenVideoPlayerByDefault) {
setTimeout(() => {
presentFullscreenPlayer();
}, 300);
}
},
},
{
@@ -230,13 +208,15 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
);
const stopPlayback = useCallback(async () => {
const id = currentlyPlaying?.item?.Id;
setCurrentlyPlayingState(null);
await reportPlaybackStopped({
api,
itemId: currentlyPlaying?.item?.Id,
itemId: id,
sessionId: session?.PlaySessionId,
positionTicks: progressTicks ? progressTicks : 0,
});
setCurrentlyPlayingState(null);
}, [currentlyPlaying?.item.Id, session?.PlaySessionId, progressTicks, api]);
const setIsPlaying = useCallback(
@@ -267,57 +247,10 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
[session?.PlaySessionId, currentlyPlaying?.item.Id, isPlaying, api]
);
const onBuffer = useCallback(
(isBuffering: boolean) => {
console.log("Buffering...", "Playing:", isPlaying);
if (
isBuffering &&
syncplayGroup?.GroupId &&
isPlaying === false &&
currentlyPlaying?.item.PlaylistItemId
) {
console.log("Sending syncplay buffering...");
getSyncPlayApi(api!).syncPlayBuffering({
bufferRequestDto: {
IsPlaying: isPlaying,
When: new Date().toISOString(),
PositionTicks: progressTicks ? progressTicks : 0,
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
},
});
}
},
[
isPlaying,
syncplayGroup?.GroupId,
currentlyPlaying?.item.PlaylistItemId,
api,
]
);
const onReady = useCallback(() => {
if (syncplayGroup?.GroupId && currentlyPlaying?.item.PlaylistItemId) {
getSyncPlayApi(api!).syncPlayReady({
readyRequestDto: {
When: new Date().toISOString(),
PlaylistItemId: currentlyPlaying?.item.PlaylistItemId,
IsPlaying: isPlaying,
PositionTicks: progressTicks ? progressTicks : 0,
},
});
}
}, [
syncplayGroup?.GroupId,
currentlyPlaying?.item.PlaylistItemId,
progressTicks,
isPlaying,
api,
]);
const onProgress = useCallback(
debounce((e: OnProgressData) => {
_onProgress(e);
}, 1000),
}, 500),
[_onProgress]
);
@@ -331,191 +264,70 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
setIsFullscreen(false);
}, []);
const seek = useCallback((ticks: number) => {
const time = ticks / 10000000;
videoRef.current?.seek(time);
}, []);
useEffect(() => {
if (!deviceId || !api?.accessToken || !user?.Id) {
console.info("[WS] Waiting for deviceId, accessToken and userId");
return;
}
if (!deviceId || !api?.accessToken) return;
const protocol = api?.basePath.includes("https") ? "wss" : "ws";
const url = `${protocol}://${api?.basePath
.replace("https://", "")
.replace("http://", "")}/socket?api_key=${
api?.accessToken
}&deviceId=${deviceId}`;
let ws: WebSocket | null = null;
const newWebSocket = new WebSocket(url);
let keepAliveInterval: NodeJS.Timeout | null = null;
const connect = () => {
ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
keepAliveInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
console.log("⬆︎ KeepAlive...");
ws.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
}, 30000);
};
ws.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
ws.onclose = () => {
setIsConnected(false);
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
newWebSocket.onopen = () => {
setIsConnected(true);
// Start sending "KeepAlive" message every 30 seconds
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
}
setTimeout(connect, 5000); // Attempt to reconnect after 5 seconds
};
setWs(ws);
}, 30000);
};
connect();
newWebSocket.onerror = (e) => {
console.error("WebSocket error:", e);
setIsConnected(false);
};
return () => {
if (ws) {
ws.close();
}
newWebSocket.onclose = (e) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
};
}, [api?.accessToken, deviceId, user]);
setWs(newWebSocket);
return () => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
newWebSocket.close();
};
}, [api, deviceId, user]);
useEffect(() => {
if (!ws || !api) return;
if (!ws) return;
ws.onmessage = (e) => {
const json = JSON.parse(e.data);
const command = json?.Data?.Command;
if (json.MessageType === "KeepAlive") {
console.log("⬇︎ KeepAlive...");
} else if (json.MessageType === "ForceKeepAlive") {
console.log("⬇︎ ForceKeepAlive...");
} else if (json.MessageType === "SyncPlayCommand") {
console.log("SyncPlayCommand ~", command, json.Data);
switch (command) {
case "Stop":
console.log("STOP");
stopPlayback();
break;
case "Pause":
console.log("PAUSE");
pauseVideo();
break;
case "Play":
case "Unpause":
console.log("PLAY");
playVideo();
break;
case "Seek":
console.log("SEEK", json.Data.PositionTicks);
seek(json.Data.PositionTicks);
break;
}
} else if (json.MessageType === "SyncPlayGroupUpdate") {
const type = json.Data.Type;
if (type === "StateUpdate") {
const data = json.Data.Data as StateUpdateData;
console.log("StateUpdate ~", data);
} else if (type === "GroupJoined") {
const data = json.Data.Data as GroupData;
setSyncplayGroup(data);
console.log("GroupJoined ~", data);
} else if (type === "GroupLeft") {
console.log("GroupLeft");
setSyncplayGroup(null);
} else if (type === "PlayQueue") {
const data = json.Data.Data as PlayQueueData;
console.log("PlayQueue ~", {
IsPlaying: data.IsPlaying,
Reason: data.Reason,
});
if (data.Reason === "SetCurrentItem") {
console.log("SetCurrentItem ~ ", json);
return;
}
if (data.Reason === "NewPlaylist") {
const itemId = data.Playlist?.[data.PlayingItemIndex].ItemId;
if (!itemId) {
console.error("No itemId found in PlayQueue");
return;
}
// Set playback item
getUserItemData({
api,
userId: user?.Id,
itemId,
}).then(async (item) => {
if (!item) {
Alert.alert("Error", "Could not find item for syncplay");
return;
}
const url = await getStreamUrl({
api,
item,
startTimeTicks: data.StartPositionTicks,
userId: user?.Id,
mediaSourceId: item?.MediaSources?.[0].Id!,
});
if (!url) {
Alert.alert("Error", "Could not find stream url for syncplay");
return;
}
await setCurrentlyPlayingState(
{
item,
url,
},
!data.IsPlaying
);
await getSyncPlayApi(api).syncPlayReady({
readyRequestDto: {
IsPlaying: data.IsPlaying,
PositionTicks: data.StartPositionTicks,
PlaylistItemId: data.Playlist[0].PlaylistItemId,
When: new Date().toISOString(),
},
});
});
}
} else {
console.log("[WS] ~ ", json);
}
return;
} else {
console.log("[WS] ~ ", json);
}
console.log("[WS] ~ ", json);
// On PlayPause
if (command === "PlayPause") {
// On PlayPause
console.log("Command ~ PlayPause");
if (isPlaying) pauseVideo();
else playVideo();
} else if (command === "Stop") {
console.log("Command ~ Stop");
stopPlayback();
router.canGoBack() && router.back();
} else if (command === "Mute") {
console.log("Command ~ Mute");
setVolume(0);
@@ -531,18 +343,18 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
Alert.alert(title, body);
}
};
}, [ws, stopPlayback, playVideo, pauseVideo, setVolume, api, seek]);
}, [ws, stopPlayback, playVideo, pauseVideo]);
return (
<PlaybackContext.Provider
value={{
onProgress,
onReady,
isBuffering,
setIsBuffering,
progressTicks,
setVolume,
setIsPlaying,
setIsFullscreen,
onBuffer,
isFullscreen,
isPlaying,
currentlyPlaying,
@@ -555,6 +367,7 @@ export const PlaybackProvider: React.FC<{ children: ReactNode }> = ({
presentFullscreenPlayer,
dismissFullscreenPlayer,
startDownloadedFilePlayback,
subtitles,
}}
>
{children}

View File

@@ -1,47 +0,0 @@
export type PlaylistItem = {
ItemId: string;
PlaylistItemId: string;
};
export type PlayQueueData = {
IsPlaying: boolean;
LastUpdate: string;
PlayingItemIndex: number;
Playlist: PlaylistItem[];
Reason: "NewPlaylist" | "SetCurrentItem"; // or use string if more values are expected
RepeatMode: "RepeatNone"; // or use string if more values are expected
ShuffleMode: "Sorted"; // or use string if more values are expected
StartPositionTicks: number;
};
export type GroupData = {
GroupId: string;
GroupName: string;
LastUpdatedAt: string;
Participants: Participant[];
State: string; // You can use an enum or union type if there are known possible states
};
export type SyncPlayCommandData = {
Command: string;
EmittedAt: string;
GroupId: string;
PlaylistItemId: string;
PositionTicks: number;
When: string;
};
export type StateUpdateData = {
State: "Waiting" | "Playing" | "Paused";
Reason: "Pause" | "Unpause";
};
export type GroupJoinedData = {
GroupId: string;
GroupName: string;
LastUpdatedAt: string;
Participants: string[];
State: "Idle";
};
export type Participant = string[];

View File

@@ -0,0 +1,21 @@
import { Orientation, OrientationLock } from "expo-screen-orientation";
function orientationToOrientationLock(
orientation: Orientation
): OrientationLock {
switch (orientation) {
case Orientation.PORTRAIT_UP:
return OrientationLock.PORTRAIT_UP;
case Orientation.PORTRAIT_DOWN:
return OrientationLock.PORTRAIT_DOWN;
case Orientation.LANDSCAPE_LEFT:
return OrientationLock.LANDSCAPE_LEFT;
case Orientation.LANDSCAPE_RIGHT:
return OrientationLock.LANDSCAPE_RIGHT;
case Orientation.UNKNOWN:
default:
return OrientationLock.DEFAULT;
}
}
export default orientationToOrientationLock;

View File

@@ -1,11 +0,0 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom } from "jotai";
export type ProcessItem = {
item: BaseItemDto;
progress: number;
speed?: number;
startTime?: Date;
};
export const runningProcesses = atom<ProcessItem | null>(null);

View File

@@ -2,12 +2,10 @@ import { atom, useAtom } from "jotai";
interface ThemeColors {
primary: string;
secondary: string;
average: string;
text: string;
}
const calculateTextColor = (backgroundColor: string): string => {
export const calculateTextColor = (backgroundColor: string): string => {
// Convert hex to RGB
const r = parseInt(backgroundColor.slice(1, 3), 16);
const g = parseInt(backgroundColor.slice(3, 5), 16);
@@ -48,26 +46,22 @@ const calculateRelativeLuminance = (rgb: number[]): number => {
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const baseThemeColorAtom = atom<ThemeColors>({
export const isCloseToBlack = (color: string): boolean => {
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
// Check if the color is very dark (close to black)
return r < 20 && g < 20 && b < 20;
};
export const adjustToNearBlack = (color: string): string => {
return "#212121"; // A very dark gray, almost black
};
export const itemThemeColorAtom = atom<ThemeColors>({
primary: "#FFFFFF",
secondary: "#000000",
average: "#888888",
text: "#000000",
});
export const itemThemeColorAtom = atom(
(get) => get(baseThemeColorAtom),
(get, set, update: Partial<ThemeColors>) => {
const currentColors = get(baseThemeColorAtom);
const newColors = { ...currentColors, ...update };
// Recalculate text color if primary color changes
if (update.average) {
newColors.text = calculateTextColor(update.average);
}
set(baseThemeColorAtom, newColors);
}
);
export const useItemThemeColor = () => useAtom(itemThemeColorAtom);

View File

@@ -1,5 +1,7 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { atom, useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { useEffect } from "react";
export interface Job {
@@ -8,8 +10,9 @@ export interface Job {
execute: () => void | Promise<void>;
}
export const runningAtom = atom<boolean>(false);
export const queueAtom = atom<Job[]>([]);
export const isProcessingAtom = atom(false);
export const queueActions = {
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
@@ -20,7 +23,7 @@ export const queueActions = {
processJob: async (
queue: Job[],
setQueue: (update: Job[]) => void,
setProcessing: (processing: boolean) => void,
setProcessing: (processing: boolean) => void
) => {
const [job, ...rest] = queue;
setQueue(rest);
@@ -28,13 +31,17 @@ export const queueActions = {
console.info("Processing job", job);
setProcessing(true);
// Excute the function assiociated with the job.
await job.execute();
console.info("Job done", job);
setProcessing(false);
},
clear: (
setQueue: (update: Job[]) => void,
setProcessing: (processing: boolean) => void,
setProcessing: (processing: boolean) => void
) => {
setQueue([]);
setProcessing(false);
@@ -43,12 +50,12 @@ export const queueActions = {
export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
const [running, setRunning] = useAtom(runningAtom);
useEffect(() => {
if (queue.length > 0 && !isProcessing) {
if (queue.length > 0 && !running) {
console.info("Processing queue", queue);
queueActions.processJob(queue, setQueue, setProcessing);
queueActions.processJob(queue, setQueue, setRunning);
}
}, [queue, isProcessing, setQueue, setProcessing]);
}, [queue, running, setQueue, setRunning]);
};

View File

@@ -1,6 +1,7 @@
import { atom, useAtom } from "jotai";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect } from "react";
import * as ScreenOrientation from "expo-screen-orientation";
export type DownloadQuality = "original" | "high" | "low";
@@ -9,6 +10,22 @@ export type DownloadOption = {
value: DownloadQuality;
};
export const ScreenOrientationEnum: Record<
ScreenOrientation.OrientationLock,
string
> = {
[ScreenOrientation.OrientationLock.DEFAULT]: "Default",
[ScreenOrientation.OrientationLock.ALL]: "All",
[ScreenOrientation.OrientationLock.PORTRAIT]: "Portrait",
[ScreenOrientation.OrientationLock.PORTRAIT_UP]: "Portrait Up",
[ScreenOrientation.OrientationLock.PORTRAIT_DOWN]: "Portrait Down",
[ScreenOrientation.OrientationLock.LANDSCAPE]: "Landscape",
[ScreenOrientation.OrientationLock.LANDSCAPE_LEFT]: "Landscape Left",
[ScreenOrientation.OrientationLock.LANDSCAPE_RIGHT]: "Landscape Right",
[ScreenOrientation.OrientationLock.OTHER]: "Other",
[ScreenOrientation.OrientationLock.UNKNOWN]: "Unknown",
};
export const DownloadOptions: DownloadOption[] = [
{
label: "Original quality",
@@ -37,10 +54,9 @@ export type DefaultLanguageOption = {
label: string;
};
type Settings = {
export type Settings = {
autoRotate?: boolean;
forceLandscapeInVideoPlayer?: boolean;
openFullScreenVideoPlayerByDefault?: boolean;
usePopularPlugin?: boolean;
deviceProfile?: "Expo" | "Native" | "Old";
forceDirectPlay?: boolean;
@@ -53,8 +69,13 @@ type Settings = {
defaultSubtitleLanguage: DefaultLanguageOption | null;
defaultAudioLanguage: DefaultLanguageOption | null;
showHomeTitles: boolean;
defaultVideoOrientation: ScreenOrientation.OrientationLock;
forwardSkipTime: number;
rewindSkipTime: number;
optimizedVersionsServerUrl?: string | null;
downloadMethod: "optimized" | "remux";
autoDownload: boolean;
};
/**
*
* The settings atom is a Jotai atom that stores the user's settings.
@@ -67,7 +88,6 @@ const loadSettings = async (): Promise<Settings> => {
const defaultValues: Settings = {
autoRotate: true,
forceLandscapeInVideoPlayer: false,
openFullScreenVideoPlayerByDefault: false,
usePopularPlugin: false,
deviceProfile: "Expo",
forceDirectPlay: false,
@@ -86,6 +106,12 @@ const loadSettings = async (): Promise<Settings> => {
defaultAudioLanguage: null,
defaultSubtitleLanguage: null,
showHomeTitles: true,
defaultVideoOrientation: ScreenOrientation.OrientationLock.DEFAULT,
forwardSkipTime: 30,
rewindSkipTime: 10,
optimizedVersionsServerUrl: null,
downloadMethod: "remux",
autoDownload: false,
};
try {

19
utils/bToMb.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Convert bits to megabits or gigabits
*
* Return nice looking string
* If under 1000Mb, return XXXMB, else return X.XGB
*/
export function convertBitsToMegabitsOrGigabits(bits?: number | null): string {
if (!bits) return "0MB";
const megabits = bits / 1000000;
if (megabits < 1000) {
return Math.round(megabits) + "MB";
} else {
const gigabits = megabits / 1000;
return gigabits.toFixed(1) + "GB";
}
}

23
utils/background-tasks.ts Normal file
View File

@@ -0,0 +1,23 @@
import * as BackgroundFetch from "expo-background-fetch";
export const BACKGROUND_FETCH_TASK = "background-fetch";
export async function registerBackgroundFetchAsync() {
try {
BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
minimumInterval: 60 * 1, // 1 minutes
stopOnTerminate: false, // android only,
startOnBoot: false, // android only
});
} catch (error) {
console.log("Error registering background fetch task", error);
}
}
export async function unregisterBackgroundFetchAsync() {
try {
BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
} catch (error) {
console.log("Error unregistering background fetch task", error);
}
}

View File

@@ -0,0 +1,55 @@
import axios from "axios";
export interface SubtitleTrack {
index: number;
name: string;
uri: string;
language: string;
default: boolean;
forced: boolean;
autoSelect: boolean;
}
export async function parseM3U8ForSubtitles(
url: string
): Promise<SubtitleTrack[]> {
try {
const response = await axios.get(url, { responseType: "text" });
const lines = response.data.split(/\r?\n/);
const subtitleTracks: SubtitleTrack[] = [];
let index = 0;
lines.forEach((line: string) => {
if (line.startsWith("#EXT-X-MEDIA:TYPE=SUBTITLES")) {
const attributes = parseAttributes(line);
const track: SubtitleTrack = {
index: index++,
name: attributes["NAME"] || "",
uri: attributes["URI"] || "",
language: attributes["LANGUAGE"] || "",
default: attributes["DEFAULT"] === "YES",
forced: attributes["FORCED"] === "YES",
autoSelect: attributes["AUTOSELECT"] === "YES",
};
subtitleTracks.push(track);
}
});
return subtitleTracks;
} catch (error) {
console.error("Failed to fetch or parse the M3U8 file:", error);
throw error;
}
}
function parseAttributes(line: string): { [key: string]: string } {
const attributes: { [key: string]: string } = {};
const parts = line.split(",");
parts.forEach((part) => {
const [key, value] = part.split("=");
if (key && value) {
attributes[key.trim()] = value.replace(/"/g, "").trim();
}
});
return attributes;
}

View File

@@ -5,6 +5,7 @@ import {
MediaSourceInfo,
PlaybackInfoResponse,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getAuthHeaders } from "../jellyfin";
export const getStreamUrl = async ({
api,
@@ -15,7 +16,7 @@ export const getStreamUrl = async ({
sessionData,
deviceProfile = ios,
audioStreamIndex = 0,
subtitleStreamIndex = 0,
subtitleStreamIndex = undefined,
forceDirectPlay = false,
height,
mediaSourceId,
@@ -25,8 +26,8 @@ export const getStreamUrl = async ({
userId: string | null | undefined;
startTimeTicks: number;
maxStreamingBitrate?: number;
sessionData?: PlaybackInfoResponse;
deviceProfile?: any;
sessionData: PlaybackInfoResponse;
deviceProfile: any;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
forceDirectPlay?: boolean;
@@ -39,6 +40,9 @@ export const getStreamUrl = async ({
const itemId = item.Id;
/**
* Build the stream URL for videos
*/
const response = await api.axiosInstance.post(
`${api.basePath}/Items/${itemId}/PlaybackInfo`,
{
@@ -58,9 +62,7 @@ export const getStreamUrl = async ({
EnableMpegtsM2TsMode: false,
},
{
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
headers: getAuthHeaders(api),
}
);
@@ -72,14 +74,16 @@ export const getStreamUrl = async ({
throw new Error("No media source");
}
if (!sessionData.PlaySessionId) {
throw new Error("no PlaySessionId");
}
let url: string | null | undefined;
if (mediaSource.SupportsDirectPlay || forceDirectPlay === true) {
if (item.MediaType === "Video") {
console.log("Using direct stream for video!");
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData?.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
url = `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${mediaSource.Id}&static=true&subtitleStreamIndex=${subtitleStreamIndex}&audioStreamIndex=${audioStreamIndex}&deviceId=${api.deviceInfo.id}&api_key=${api.accessToken}`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
UserId: userId,
DeviceId: api.deviceInfo.id,
@@ -90,9 +94,7 @@ export const getStreamUrl = async ({
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
PlaySessionId: sessionData?.PlaySessionId
? sessionData.PlaySessionId
: "",
PlaySessionId: sessionData.PlaySessionId,
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
@@ -102,11 +104,17 @@ export const getStreamUrl = async ({
}/Audio/${itemId}/universal?${searchParams.toString()}`;
}
} else if (mediaSource.TranscodingUrl) {
console.log("Using transcoded stream!");
url = `${api.basePath}${mediaSource.TranscodingUrl}`;
}
if (!url) throw new Error("No url");
console.log(
mediaSource.VideoType,
mediaSource.Container,
mediaSource.TranscodingContainer,
mediaSource.TranscodingSubProtocol
);
return url;
};

View File

@@ -1,6 +1,7 @@
import { Api } from "@jellyfin/sdk";
import { getAuthHeaders } from "../jellyfin";
import { postCapabilities } from "../session/capabilities";
import { Settings } from "@/utils/atoms/settings";
interface ReportPlaybackProgressParams {
api?: Api | null;
@@ -8,6 +9,7 @@ interface ReportPlaybackProgressParams {
itemId?: string | null;
positionTicks?: number | null;
IsPaused?: boolean;
deviceProfile?: Settings["deviceProfile"];
}
/**
@@ -22,6 +24,7 @@ export const reportPlaybackProgress = async ({
itemId,
positionTicks,
IsPaused = false,
deviceProfile,
}: ReportPlaybackProgressParams): Promise<void> => {
if (!api || !sessionId || !itemId || !positionTicks) {
return;
@@ -34,6 +37,7 @@ export const reportPlaybackProgress = async ({
api,
itemId,
sessionId,
deviceProfile,
});
} catch (error) {
console.error("Failed to post capabilities.", error);

View File

@@ -1,7 +1,6 @@
import { Api } from "@jellyfin/sdk";
import { AxiosError } from "axios";
import { getAuthHeaders } from "../jellyfin";
import { writeToLog } from "@/utils/log";
interface PlaybackStoppedParams {
api: Api | null | undefined;
@@ -28,23 +27,17 @@ export const reportPlaybackStopped = async ({
if (!positionTicks || positionTicks === 0) return;
if (!api) {
writeToLog("WARN", "Could not report playback stopped due to missing api");
console.error("Missing api");
return;
}
if (!sessionId) {
writeToLog(
"WARN",
"Could not report playback stopped due to missing session id"
);
console.error("Missing sessionId", sessionId);
return;
}
if (!itemId) {
writeToLog(
"WARN",
"Could not report playback progress due to missing item id"
);
console.error("Missing itemId");
return;
}

View File

@@ -1,16 +1,17 @@
import { Settings } from "@/utils/atoms/settings";
import ios from "@/utils/profiles/ios";
import native from "@/utils/profiles/native";
import old from "@/utils/profiles/old";
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, AxiosResponse } from "axios";
import { useMemo } from "react";
import { getAuthHeaders } from "../jellyfin";
interface PostCapabilitiesParams {
api: Api | null | undefined;
itemId: string | null | undefined;
sessionId: string | null | undefined;
deviceProfile: Settings["deviceProfile"];
}
/**
@@ -23,16 +24,26 @@ export const postCapabilities = async ({
api,
itemId,
sessionId,
deviceProfile,
}: PostCapabilitiesParams): Promise<AxiosResponse> => {
if (!api || !itemId || !sessionId) {
throw new Error("Missing parameters for marking item as not played");
}
let profile: any = ios;
if (deviceProfile === "Native") {
profile = native;
}
if (deviceProfile === "Old") {
profile = old;
}
try {
const d = api.axiosInstance.post(
api.basePath + "/Sessions/Capabilities/Full",
{
playableMediaTypes: ["Audio", "Video", "Audio"],
playableMediaTypes: ["Audio", "Video"],
supportedCommands: [
"PlayState",
"Play",
@@ -45,6 +56,7 @@ export const postCapabilities = async ({
],
supportsMediaControl: true,
id: sessionId,
DeviceProfile: profile,
},
{
headers: getAuthHeaders(api),

3
utils/mmkv.ts Normal file
View File

@@ -0,0 +1,3 @@
import { MMKV } from "react-native-mmkv";
export const storage = new MMKV();

154
utils/optimize-server.ts Normal file
View File

@@ -0,0 +1,154 @@
import { itemRouter } from "@/components/common/TouchableItemRouter";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import axios from "axios";
interface IJobInput {
deviceId?: string | null;
authHeader?: string | null;
url?: string | null;
}
export interface JobStatus {
id: string;
status:
| "queued"
| "optimizing"
| "completed"
| "failed"
| "cancelled"
| "downloading";
progress: number;
outputPath: string;
inputUrl: string;
deviceId: string;
itemId: string;
item: Partial<BaseItemDto>;
speed?: number;
timestamp: Date;
base64Image?: string;
}
/**
* Fetches all jobs for a specific device.
*
* @param {IGetAllDeviceJobs} params - The parameters for the API request.
* @param {string} params.deviceId - The ID of the device to fetch jobs for.
* @param {string} params.authHeader - The authorization header for the API request.
* @param {string} params.url - The base URL for the API endpoint.
*
* @returns {Promise<JobStatus[]>} A promise that resolves to an array of job statuses.
*
* @throws {Error} Throws an error if the API request fails or returns a non-200 status code.
*/
export async function getAllJobsByDeviceId({
deviceId,
authHeader,
url,
}: IJobInput): Promise<JobStatus[]> {
const statusResponse = await axios.get(`${url}all-jobs`, {
headers: {
Authorization: authHeader,
},
params: {
deviceId,
},
});
if (statusResponse.status !== 200) {
console.error(
statusResponse.status,
statusResponse.data,
statusResponse.statusText
);
throw new Error("Failed to fetch job status");
}
return statusResponse.data;
}
interface ICancelJob {
authHeader: string;
url: string;
id: string;
}
export async function cancelJobById({
authHeader,
url,
id,
}: ICancelJob): Promise<boolean> {
const statusResponse = await axios.delete(`${url}cancel-job/${id}`, {
headers: {
Authorization: authHeader,
},
});
if (statusResponse.status !== 200) {
throw new Error("Failed to cancel process");
}
return true;
}
export async function cancelAllJobs({ authHeader, url, deviceId }: IJobInput) {
if (!deviceId) return false;
if (!authHeader) return false;
if (!url) return false;
try {
await getAllJobsByDeviceId({
deviceId,
authHeader,
url,
}).then((jobs) => {
jobs.forEach((job) => {
cancelJobById({
authHeader,
url,
id: job.id,
});
});
});
} catch (error) {
console.error(error);
return false;
}
return true;
}
/**
* Fetches statistics for a specific device.
*
* @param {IJobInput} params - The parameters for the API request.
* @param {string} params.deviceId - The ID of the device to fetch statistics for.
* @param {string} params.authHeader - The authorization header for the API request.
* @param {string} params.url - The base URL for the API endpoint.
*
* @returns {Promise<any | null>} A promise that resolves to the statistics data or null if the request fails.
*
* @throws {Error} Throws an error if any required parameter is missing.
*/
export async function getStatistics({
authHeader,
url,
deviceId,
}: IJobInput): Promise<any | null> {
if (!deviceId || !authHeader || !url) {
return null;
}
try {
const statusResponse = await axios.get(`${url}statistics`, {
headers: {
Authorization: authHeader,
},
params: {
deviceId,
},
});
return statusResponse.data;
} catch (error) {
console.error("Failed to fetch statistics:", error);
return null;
}
}

5
utils/secondsToTicks.ts Normal file
View File

@@ -0,0 +1,5 @@
// seconds to ticks util
export function secondsToTicks(seconds: number): number {
return seconds * 10000000;
}

View File

@@ -6,7 +6,7 @@
* @returns A string formatted as "Xh Ym" where X is hours and Y is minutes.
*/
export const runtimeTicksToMinutes = (
ticks: number | null | undefined,
ticks: number | null | undefined
): string => {
if (!ticks) return "0h 0m";
@@ -20,7 +20,7 @@ export const runtimeTicksToMinutes = (
};
export const runtimeTicksToSeconds = (
ticks: number | null | undefined,
ticks: number | null | undefined
): string => {
if (!ticks) return "0h 0m";
@@ -34,3 +34,37 @@ export const runtimeTicksToSeconds = (
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
else return `${minutes}m ${seconds}s`;
};
export const formatTimeString = (
t: number | null | undefined,
tick = false
): string => {
if (t === null || t === undefined) return "0:00";
let seconds = t;
if (tick) {
seconds = Math.floor(t / 10000000); // Convert ticks to seconds
}
if (seconds < 0) return "0:00";
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m ${remainingSeconds}s`;
} else {
return `${minutes}m ${remainingSeconds}s`;
}
};
export const secondsToTicks = (seconds?: number | undefined) => {
if (!seconds) return 0;
return seconds * 10000000;
};
export const ticksToSeconds = (ticks?: number | undefined) => {
if (!ticks) return 0;
return ticks / 10000000;
};