Compare commits

..

40 Commits

Author SHA1 Message Date
Fredrik Burmester
ad8bc954c1 feat: download queue 2024-08-14 13:30:43 +02:00
Fredrik Burmester
f87824ec58 chore 2024-08-14 13:30:35 +02:00
Fredrik Burmester
78556e8764 fix: missing or incorrect pts Issues 2024-08-14 11:10:52 +02:00
Fredrik Burmester
3c678add0f fix: limit 2024-08-14 10:47:31 +02:00
Fredrik Burmester
0c98980b1d feat: ratings 2024-08-14 10:46:19 +02:00
Fredrik Burmester
66179a68ea fix: refactor 2024-08-14 10:46:15 +02:00
Fredrik Burmester
fdd07dce3b chore 2024-08-14 10:10:45 +02:00
Fredrik Burmester
0dc32d58cf chore 2024-08-14 10:10:15 +02:00
Fredrik Burmester
e56c3e5c97 fix: error message 2024-08-14 10:10:07 +02:00
Fredrik Burmester
bd8bf8349f fix: move cast button + ask user which device to play on 2024-08-14 10:09:59 +02:00
Fredrik Burmester
ede390e74b fix: design 2024-08-14 10:09:20 +02:00
Fredrik Burmester
0eca453c9a fix: try to improve download speed 2024-08-14 08:59:43 +02:00
Fredrik Burmester
65838034b6 fix: design 2024-08-14 08:59:25 +02:00
Fredrik Burmester
e715b3daa4 fix: design 2024-08-14 08:59:21 +02:00
Fredrik Burmester
37b7fc1c20 fix: report pause playback on pause 2024-08-14 08:59:17 +02:00
Fredrik Burmester
9ee30ff1ce feat: more bitrate options 2024-08-14 08:58:53 +02:00
Fredrik Burmester
026a286ebf fix: re-fetch hls stream url on sub change 2024-08-14 08:58:42 +02:00
Fredrik Burmester
e522e1dcc0 chore: deps 2024-08-14 08:13:29 +02:00
Fredrik Burmester
a80e065cdb fix: formatting of specials season number 2024-08-14 08:13:14 +02:00
Fredrik Burmester
f4f2d37aea fix: add headers to hls stream url 2024-08-14 08:13:02 +02:00
Fredrik Burmester
e65ed3db0e fix: text 2024-08-13 21:00:33 +02:00
Fredrik Burmester
cb9dfe2c83 feat: app store link 2024-08-13 21:00:08 +02:00
Fredrik Burmester
bc4b07c76b fix: padding 2024-08-13 20:29:40 +02:00
Fredrik Burmester
150eb1809f feat: prev/next episode buttons 2024-08-13 20:26:02 +02:00
Fredrik Burmester
8afe7dc5e4 chore: version 2024-08-13 20:25:50 +02:00
Fredrik Burmester
855e00a676 fix: refactor 2024-08-13 20:25:30 +02:00
Fredrik Burmester
5289c0519f chore: update readme 2024-08-13 16:43:58 +02:00
Fredrik Burmester
4b1eb2218f fix: invalid config 2024-08-13 16:43:50 +02:00
Fredrik Burmester
a99e7b950e fix: pip 2024-08-13 16:21:56 +02:00
Fredrik Burmester
51fc2a0edb chore: versions 2024-08-13 16:02:47 +02:00
Fredrik Burmester
3a13503d1d fix: enable background playback 2024-08-13 16:01:52 +02:00
Fredrik Burmester
2fdf90ab4b fix: enable background play 2024-08-13 16:00:50 +02:00
Fredrik Burmester
6fed0c1c77 fix: change colors 2024-08-13 16:00:43 +02:00
Fredrik Burmester
ee7ff3444e chore: todo 2024-08-13 16:00:39 +02:00
Fredrik Burmester
dec175a300 fix: route to download page 2024-08-13 16:00:26 +02:00
Fredrik Burmester
27099d3184 fix: improve pause/play logic 2024-08-13 16:00:07 +02:00
Fredrik Burmester
bfad77dd7a fix: change color to purple 2024-08-13 15:59:34 +02:00
Fredrik Burmester
74a33f8f82 fix: update download list when a download is finished 2024-08-13 15:59:27 +02:00
Fredrik Burmester
75de878618 fix: show loader for videos but not music 2024-08-13 14:41:37 +02:00
Fredrik Burmester
9628285701 fix: remove parsing of the url 2024-08-13 14:00:18 +02:00
40 changed files with 963 additions and 420 deletions

5
.gitignore vendored
View File

@@ -27,6 +27,5 @@ Streamyfin.app
pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
development.apk
Streamyfin.apk
Streamyfin.ipa
*.apk
*.ipa

View File

@@ -12,10 +12,11 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
## 🌟 Features
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
- 📱 Native video player: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 📥 Download media (Experimental): Save your media locally and watch it offline.
- 📡 Chromecast media (Experimental): Cast your media to any Chromecast-enabled device.
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
## 🧪 Experimental Features
@@ -25,15 +26,21 @@ Streamyfin includes some exciting experimental features like media downloading a
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
## 🛠️ Beta testing (iOS/Android)
## Get it now
## TestFlight
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
<img height=75 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
</a>
### TestFlight
Get the latest updates by using the TestFlight version of the app.
<a href="https://testflight.apple.com/join/CWBaAAK2">
<img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a>
## Play Store Open Beta
### Play Store Open Beta
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>

View File

@@ -2,8 +2,8 @@
"expo": {
"name": "Streamyfin",
"slug": "streamyfin",
"version": "0.3.4",
"orientation": "portrait",
"version": "0.4.2",
"orientation": "default",
"icon": "./assets/images/icon.png",
"scheme": "streamyfin",
"userInterfaceStyle": "dark",
@@ -18,20 +18,24 @@
"requireFullScreen": true,
"infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
"NSMicrophoneUsageDescription": "The app needs access to your microphone."
"NSMicrophoneUsageDescription": "The app needs access to your microphone.",
"UIBackgroundModes": ["audio"],
"NSLocalNetworkUsageDescription": "The app needs access to your local network to connect to your Jellyfin server.",
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true,
"NSExceptionDomains": {
"*": {
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
}
},
"supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin"
},
"android": {
"jsEngine": "hermes",
"versionCode": 12,
"orientation": "default",
"androidNavigationBar": {
"visible": true,
"barStyle": "dark-content",
"backgroundColor": "#000000"
},
"versionCode": 15,
"adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png"
},

View File

@@ -3,8 +3,9 @@ import React, { useEffect } from "react";
import * as NavigationBar from "expo-navigation-bar";
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors";
import { Platform, TouchableOpacity } from "react-native";
import { Platform, TouchableOpacity, View } from "react-native";
import { Feather } from "@expo/vector-icons";
import { Chromecast } from "@/components/Chromecast";
export default function TabLayout() {
useEffect(() => {
@@ -41,18 +42,23 @@ export default function TabLayout() {
router.push("/(auth)/downloads");
}}
>
<Feather name="download" color={"white"} size={24} />
<Feather name="download" color={"white"} size={22} />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
style={{ marginHorizontal: 17 }}
onPress={() => {
router.push("/(auth)/settings");
}}
>
<Feather name="settings" color={"white"} size={24} />
</TouchableOpacity>
<View className="flex flex-row items-center space-x-2">
<Chromecast />
<TouchableOpacity
style={{ marginRight: 17 }}
onPress={() => {
router.push("/(auth)/settings");
}}
>
<View className="h-10 aspect-square flex items-center justify-center rounded">
<Feather name="settings" color={"white"} size={22} />
</View>
</TouchableOpacity>
</View>
),
}}
/>

View File

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

View File

@@ -1,23 +1,16 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem";
import { PlayedStatus } from "@/components/PlayedStatus";
import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SimilarItems } from "@/components/SimilarItems";
import { VideoPlayer } from "@/components/VideoPlayer";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { router, useLocalSearchParams } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react";
import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { ParallaxScrollView } from "../../../../components/ParallaxPage";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
@@ -36,6 +29,11 @@ import ios12 from "@/utils/profiles/ios12";
import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar";
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { SubtitleTrackSelector } from "@/components/SubtitleTrackSelector";
import { NextEpisodeButton } from "@/components/series/NextEpisodeButton";
import { Ratings } from "@/components/Ratings";
import { SeriesTitleHeader } from "@/components/series/SeriesTitleHeader";
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
import { OverviewText } from "@/components/OverviewText";
const page: React.FC = () => {
const local = useLocalSearchParams();
@@ -105,6 +103,7 @@ const page: React.FC = () => {
maxBitrate,
castDevice,
selectedAudioStream,
selectedSubtitleStream,
],
queryFn: async () => {
if (!api || !user?.Id || !sessionData) return null;
@@ -129,38 +128,41 @@ const page: React.FC = () => {
staleTime: 0,
});
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const client = useRemoteMediaClient();
const onPressPlay = useCallback(async () => {
if (!playbackUrl || !item) return;
const onPressPlay = useCallback(
async (type: "device" | "cast" = "device") => {
if (!playbackUrl || !item) return;
if (chromecastReady && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: playbackUrl,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
if (type === "cast" && client) {
await CastContext.getPlayServicesState().then((state) => {
if (state && state !== PlayServicesState.SUCCESS)
CastContext.showPlayServicesErrorDialog(state);
else {
client.loadMedia({
mediaInfo: {
contentUrl: playbackUrl,
contentType: "video/mp4",
metadata: {
type: item.Type === "Episode" ? "tvShow" : "movie",
title: item.Name || "",
subtitle: item.Overview || "",
},
},
},
startTime: 0,
});
}
});
} else {
setCp({
item,
playbackUrl,
});
}
}, [playbackUrl, item]);
startTime: 0,
});
}
});
} else {
setCp({
item,
playbackUrl,
});
}
},
[playbackUrl, item],
);
if (l1)
return (
@@ -201,66 +203,31 @@ const page: React.FC = () => {
</>
}
>
<View className="flex flex-col px-4 mb-4 pt-4">
<View className="flex flex-col px-4 pt-4">
<View className="flex flex-col">
{item.Type === "Episode" ? (
<>
<TouchableOpacity
onPress={() =>
router.push(`/(auth)/series/${item.SeriesId}/page`)
}
>
<Text className="text-center opacity-50">
{item?.SeriesName}
</Text>
</TouchableOpacity>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
<PlayedStatus item={item} />
</View>
<View>
<View className="flex flex-row items-center self-center">
<TouchableOpacity onPress={() => {}}>
<Text className="text-center opacity-50">
{item?.SeasonName}
</Text>
</TouchableOpacity>
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
<Text className="text-center opacity-50">
{`Episode ${item.IndexNumber}`}
</Text>
</View>
</View>
<Text className="text-center opacity-50">
{item.ProductionYear}
</Text>
</>
<SeriesTitleHeader item={item} />
) : (
<>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
<PlayedStatus item={item} />
</View>
<Text className="text-center opacity-50">
{item?.ProductionYear}
</Text>
<MoviesTitleHeader item={item} />
</>
)}
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
<Ratings item={item} />
</View>
<View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl && (
{playbackUrl ? (
<DownloadItem item={item} playbackUrl={playbackUrl} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)}
<Chromecast />
<PlayedStatus item={item} />
</View>
<Text>{item.Overview}</Text>
<OverviewText text={item.Overview} />
</View>
<View className="flex flex-col p-4">
<View className="flex flex-col p-4 w-full">
<View className="flex flex-row items-center space-x-2 w-full">
<BitrateSelector
onChange={(val) => setMaxBitrate(val)}
@@ -277,7 +244,16 @@ const page: React.FC = () => {
selected={selectedSubtitleStream}
/>
</View>
<PlayButton item={item} chromecastReady={false} onPress={onPressPlay} />
<View className="flex flex-row items-center justify-between w-full">
<NextEpisodeButton item={item} type="previous" className="mr-2" />
<PlayButton
item={item}
chromecastReady={chromecastReady}
onPress={onPressPlay}
className="grow"
/>
<NextEpisodeButton item={item} className="ml-2" />
</View>
</View>
<ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 ">

View File

@@ -38,7 +38,7 @@ const page: React.FC = () => {
itemId: seriesId,
}),
enabled: !!seriesId && !!api,
staleTime: 0,
staleTime: 60,
});
const backdropUrl = useMemo(

View File

@@ -9,8 +9,11 @@ import { useEffect, useRef, useState } from "react";
import "react-native-reanimated";
import * as ScreenOrientation from "expo-screen-orientation";
import { StatusBar } from "expo-status-bar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { useJobProcessor } from "@/utils/atoms/queue";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { useKeepAwake } from "expo-keep-awake";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -20,6 +23,8 @@ export const unstable_settings = {
};
export default function RootLayout() {
useKeepAwake();
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
@@ -75,67 +80,71 @@ export default function RootLayout() {
return (
<QueryClientProvider client={queryClientRef.current}>
<JotaiProvider>
<JellyfinProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "Home",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]/page"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collection]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]/page"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</JellyfinProvider>
<JobQueueProvider>
<ActionSheetProvider>
<JellyfinProvider>
<StatusBar style="light" backgroundColor="#000" />
<ThemeProvider value={DarkTheme}>
<Stack>
<Stack.Screen
name="(auth)/(tabs)"
options={{
headerShown: false,
title: "Home",
}}
/>
<Stack.Screen
name="(auth)/settings"
options={{
headerShown: true,
title: "Settings",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/downloads"
options={{
headerShown: true,
title: "Downloads",
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/items/[id]/page"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="(auth)/collections/[collection]/page"
options={{
title: "",
headerShown: true,
headerStyle: { backgroundColor: "black" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="(auth)/series/[id]/page"
options={{
title: "",
headerShown: false,
}}
/>
<Stack.Screen
name="login"
options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
<CurrentlyPlayingBar />
</ThemeProvider>
</JellyfinProvider>
</ActionSheetProvider>
</JobQueueProvider>
</JotaiProvider>
</QueryClientProvider>
);

View File

@@ -38,42 +38,15 @@ const Login: React.FC = () => {
await login(credentials.username, credentials.password);
}
} catch (error) {
const e = error as AxiosError | z.ZodError;
if (e instanceof z.ZodError) {
setError("An error occured.");
} else {
if (e.response?.status === 401) {
setError("Invalid credentials.");
} else {
setError(
"A network error occurred. Did you enter the correct server URL?",
);
}
}
const e = error as AxiosError;
setError(e.message);
} finally {
setLoading(false);
}
};
const parsedServerURL = useMemo(() => {
let parsedServerURL = serverURL.trim();
if (parsedServerURL) {
parsedServerURL = parsedServerURL.endsWith("/")
? parsedServerURL.replace("/", "")
: parsedServerURL;
parsedServerURL = parsedServerURL.startsWith("http")
? parsedServerURL
: "http://" + parsedServerURL;
return parsedServerURL;
}
return "";
}, [serverURL]);
const handleConnect = (url: string) => {
setServer({ address: url });
setServer({ address: url.trim() });
};
if (api?.basePath) {
@@ -165,9 +138,7 @@ const Login: React.FC = () => {
textContentType="URL"
maxLength={500}
/>
<Button onPress={() => handleConnect(parsedServerURL)}>
Connect
</Button>
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
</View>
</View>
</KeyboardAvoidingView>

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
bun.lockb

Binary file not shown.

36
components/Badge.tsx Normal file
View File

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

View File

@@ -13,6 +13,10 @@ const BITRATES: Bitrate[] = [
key: "Max",
value: undefined,
},
{
key: "8 Mb/s",
value: 8000000,
},
{
key: "4 Mb/s",
value: 4000000,
@@ -25,6 +29,10 @@ const BITRATES: Bitrate[] = [
key: "500 Kb/s",
value: 500000,
},
{
key: "250 Kb/s",
value: 250000,
},
];
interface Props extends React.ComponentProps<typeof View> {

View File

@@ -7,7 +7,7 @@ interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
className?: string;
textClassName?: string;
disabled?: boolean;
children?: string;
children?: string | ReactNode;
loading?: boolean;
color?: "purple" | "red" | "black";
iconRight?: ReactNode;

View File

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

View File

@@ -61,7 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
style={{
width: `${progress}%`,
}}
className={`absolute bottom-0 left-0 h-1 bg-red-600 w-full`}
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
></View>
</>
)}

View File

@@ -7,23 +7,13 @@ import {
import { Text } from "./common/Text";
import { Ionicons } from "@expo/vector-icons";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Video, {
OnProgressData,
SelectedTrack,
SelectedTrackType,
VideoRef,
} from "react-native-video";
import Video, { OnProgressData, VideoRef } from "react-native-video";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { chromecastProfile } from "@/utils/profiles/chromecast";
import ios12 from "@/utils/profiles/ios12";
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import Animated, {
@@ -35,7 +25,7 @@ import { useRouter, useSegments } from "expo-router";
import { BlurView } from "expo-blur";
import { writeToLog } from "@/utils/log";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { Image } from "expo-image";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
@@ -43,13 +33,10 @@ export const currentlyPlayingItemAtom = atom<{
} | null>(null);
export const CurrentlyPlayingBar: React.FC = () => {
const insets = useSafeAreaInsets();
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
const castDevice = useCastDevice();
const client = useRemoteMediaClient();
const queryClient = useQueryClient();
const segments = useSegments();
@@ -198,7 +185,7 @@ export const CurrentlyPlayingBar: React.FC = () => {
}
}, [cp?.playbackUrl]);
if (!cp) return null;
if (!cp || !api) return null;
return (
<Animated.View
@@ -229,14 +216,23 @@ export const CurrentlyPlayingBar: React.FC = () => {
{cp.playbackUrl && (
<Video
ref={videoRef}
allowsExternalPlayback
style={{ width: "100%", height: "100%" }}
allowsExternalPlayback={true}
playInBackground={true}
playWhenInactive={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
controls={false}
poster={backdropUrl ? backdropUrl : undefined}
pictureInPicture={true}
poster={
backdropUrl && item?.Type === "Audio"
? backdropUrl
: undefined
}
debug={{
enable: true,
thread: true,
}}
paused={paused}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
@@ -246,13 +242,21 @@ export const CurrentlyPlayingBar: React.FC = () => {
uri: cp.playbackUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
}}
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onFullscreenPlayerDidDismiss={() => {
play();
onPlaybackStateChanged={(e) => {
if (e.isPlaying) {
setPaused(false);
} else if (e.isSeeking) {
return;
} else {
pause();
}
}}
progressUpdateInterval={1000}
onError={(e) => {
console.log(e);
writeToLog(
@@ -261,15 +265,12 @@ export const CurrentlyPlayingBar: React.FC = () => {
);
}}
renderLoader={
item?.Type === "Video" && (
item?.Type !== "Audio" && (
<View className="flex flex-col items-center justify-center h-full">
<ActivityIndicator size={"small"} color={"white"} />
</View>
)
}
subtitleStyle={{
fontSize: 20,
}}
/>
)}
</TouchableOpacity>

View File

@@ -1,18 +1,16 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads";
import { queueActions, queueAtom } from "@/utils/atoms/queue";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
import Ionicons from "@expo/vector-icons/Ionicons";
import { BaseItemDto } 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, useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle";
import { Text } from "./common/Text";
import { useDownloadMedia } from "@/hooks/useDownloadMedia";
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
type DownloadProps = {
item: BaseItemDto;
@@ -26,119 +24,114 @@ export const DownloadItem: React.FC<DownloadProps> = ({
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { downloadMedia, isDownloading, error, cancelDownload } =
useDownloadMedia(api, user?.Id);
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
});
const downloadFile = useCallback(async () => {
if (!playbackInfo) return;
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
queryKey: ["downloaded", item.Id],
queryFn: async () => {
if (!item.Id) return false;
const source = playbackInfo.MediaSources?.[0];
if (source?.SupportsDirectPlay && item.CanDownload) {
downloadMedia(item);
} else {
throw new Error(
"Direct play not supported thus the file cannot be downloaded",
);
}
}, [item, user, playbackInfo]);
const [downloaded, setDownloaded] = useState<boolean>(false);
useEffect(() => {
(async () => {
const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]",
);
if (data.find((d) => d.Id === item.Id)) setDownloaded(true);
})();
}, [process]);
return data.some((d) => d.Id === item.Id);
},
enabled: !!item.Id,
});
if (isLoading) {
return <ActivityIndicator size={"small"} color={"white"} />;
if (isLoading || isLoadingDownloaded) {
return (
<View className="rounded h-10 aspect-square flex items-center justify-center">
<ActivityIndicator size={"small"} color={"white"} />
</View>
);
}
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
return (
<View style={{ opacity: 0.5 }}>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="cloud-download-outline" size={24} color="white" />
</View>
);
}
if (process && process.item.Id !== item.Id!) {
if (process && process?.item.Id === item.Id) {
return (
<TouchableOpacity onPress={() => {}} style={{ opacity: 0.5 }}>
<Ionicons name="cloud-download-outline" size={24} color="white" />
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<View className="-rotate-45">
<ProgressCircle
size={24}
fill={process.progress}
width={4}
tintColor="#9334E9"
backgroundColor="#bdc3c7"
/>
</View>
)}
</View>
</TouchableOpacity>
);
}
return (
<View>
{process ? (
<TouchableOpacity
onPress={() => {
cancelRemuxing();
}}
className="flex flex-row items-center"
>
{process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} />
) : (
<View className="relative">
<View className="-rotate-45">
<ProgressCircle
size={28}
fill={process.progress}
width={4}
tintColor="#3498db"
backgroundColor="#bdc3c7"
/>
</View>
<View className="absolute top-0 left-0 font-bold w-full h-full flex flex-col items-center justify-center">
<Text className="text-[7px]">
{process.progress.toFixed(0)}%
</Text>
</View>
</View>
)}
if (queue.some((i) => i.id === item.Id)) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
<Ionicons name="hourglass" size={24} color="white" />
</View>
</TouchableOpacity>
);
}
{process?.speed && process.speed > 0 ? (
<View className="ml-2">
<Text>{process.speed.toFixed(2)}x</Text>
</View>
) : null}
</TouchableOpacity>
) : downloaded ? (
<TouchableOpacity
onPress={() => {
router.push(
`/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`,
);
}}
>
<Ionicons name="cloud-download" size={26} color="#16a34a" />
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => {
// downloadFile();
startRemuxing();
}}
>
if (downloaded) {
return (
<TouchableOpacity
onPress={() => {
router.push("/downloads");
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download" size={26} color="#9333ea" />
</View>
</TouchableOpacity>
);
} else {
return (
<TouchableOpacity
onPress={() => {
queueActions.enqueue(queue, setQueue, {
id: item.Id!,
execute: async () => {
await startRemuxing();
},
item,
});
}}
>
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="cloud-download-outline" size={26} color="white" />
</TouchableOpacity>
)}
</View>
);
</View>
</TouchableOpacity>
);
}
};

View File

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

View File

@@ -0,0 +1,56 @@
import { useVideoPlayer, VideoView } from "expo-video";
import { useEffect, useRef, useState } from "react";
import {
PixelRatio,
StyleSheet,
View,
Button,
TouchableOpacity,
} from "react-native";
const videoSource =
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
interface Props {
videoSource: string;
}
export const NewVideoPlayer: React.FC<Props> = ({ videoSource }) => {
const ref = useRef<VideoView | null>(null);
const [isPlaying, setIsPlaying] = useState(true);
const player = useVideoPlayer(videoSource, (player) => {
player.loop = true;
player.play();
});
useEffect(() => {
const subscription = player.addListener("playingChange", (isPlaying) => {
setIsPlaying(isPlaying);
});
return () => {
subscription.remove();
};
}, [player]);
return (
<TouchableOpacity
onPress={() => {
ref.current?.enterFullscreen();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
`}
>
<VideoView
ref={ref}
style={{
width: "100%",
height: "100%",
}}
player={player}
allowsFullscreen
allowsPictureInPicture
/>
</TouchableOpacity>
);
};

View File

@@ -30,7 +30,7 @@ type VideoPlayerProps = {
onChangePlaybackURL: (url: string | null) => void;
};
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
itemId,
onChangePlaybackURL,
}) => {

View File

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

View File

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

View File

@@ -1,32 +1,63 @@
import { useState } from "react";
import { Button } from "./Button";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
import { useAtom } from "jotai";
import { Feather, Ionicons } from "@expo/vector-icons";
import { runtimeTicksToMinutes } from "@/utils/time";
import { useActionSheet } from "@expo/react-native-action-sheet";
import { View } from "react-native";
type Props = {
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
onPress: () => void;
onPress: (type?: "cast" | "device") => void;
chromecastReady: boolean;
};
}
export const PlayButton: React.FC<Props> = ({
item,
onPress,
chromecastReady,
...props
}) => {
const { showActionSheetWithOptions } = useActionSheet();
const _onPress = () => {
if (!chromecastReady) {
onPress("device");
return;
}
const options = ["Chromecast", "Device", "Cancel"];
const cancelButtonIndex = 2;
showActionSheetWithOptions(
{
options,
cancelButtonIndex,
},
(selectedIndex: number | undefined) => {
switch (selectedIndex) {
case 0:
onPress("cast");
break;
case 1:
onPress("device");
break;
case cancelButtonIndex:
console.log("calcel");
}
},
);
};
return (
<Button
onPress={onPress}
onPress={_onPress}
iconRight={
chromecastReady ? (
<Feather name="cast" size={20} color="white" />
) : (
<View className="flex flex-row items-center space-x-2">
<Ionicons name="play-circle" size={24} color="white" />
)
{chromecastReady && <Feather name="cast" size={22} color="white" />}
</View>
}
{...props}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Button>

View File

@@ -47,7 +47,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
invalidateQueries();
}}
>
<Ionicons name="checkmark-circle" size={26} color="white" />
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle" size={30} color="white" />
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
@@ -61,7 +63,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
invalidateQueries();
}}
>
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
<View className="rounded h-10 aspect-square flex items-center justify-center">
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
</View>
</TouchableOpacity>
)}
</View>

41
components/Ratings.tsx Normal file
View File

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

12
components/_template.tsx Normal file
View File

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

View File

@@ -13,6 +13,22 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles();
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
// const fetchFileSize = async () => {
// try {
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
// const info = await FileSystem.getInfoAsync(filePath);
// return info.exists ? info.size : null;
// } catch (e) {
// console.log(e);
// return null;
// }
// };
// const { data: fileSize } = useQuery({
// queryKey: ["fileSize", item?.Id],
// queryFn: fetchFileSize,
// });
const openFile = useCallback(() => {
setCp({
item,
@@ -43,6 +59,12 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
<Text className=" text-xs opacity-50">
Episode {item.IndexNumber}
</Text>
{/* <Text className=" text-xs opacity-50">
Size:{" "}
{fileSize
? `${(fileSize / 1000000).toFixed(0)} MB`
: "Calculating..."}{" "}
</Text> */}
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content

View File

@@ -9,11 +9,23 @@ import { useCallback } from "react";
import * as Haptics from "expo-haptics";
import { useAtom } from "jotai";
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
import { useQuery } from "@tanstack/react-query";
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles();
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
// const fetchFileSize = async () => {
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
// const info = await FileSystem.getInfoAsync(filePath);
// return info.exists ? info.size : null;
// };
// const { data: fileSize } = useQuery({
// queryKey: ["fileSize", item?.Id],
// queryFn: fetchFileSize,
// });
const openFile = useCallback(() => {
setCp({
item,
@@ -41,11 +53,17 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
>
<Text className=" font-bold">{item.Name}</Text>
<View className="flex flex-row items-center justify-between">
<View className="flex flex-col">
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
<Text className=" text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)}
</Text>
{/* <Text className=" text-xs opacity-50">
Size:{" "}
{fileSize
? `${(fileSize / 1000000).toFixed(0)} MB`
: "Calculating..."}{" "}
</Text>*/}
</View>
</TouchableOpacity>
</ContextMenu.Trigger>

View File

@@ -0,0 +1,21 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
</View>
</>
);
};

View File

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

View File

@@ -0,0 +1,37 @@
import { TouchableOpacity, View, ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
import { useRouter } from "expo-router";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
interface Props extends ViewProps {
item: BaseItemDto;
}
export const SeriesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
const router = useRouter();
return (
<>
<TouchableOpacity
onPress={() => router.push(`/(auth)/series/${item.SeriesId}/page`)}
>
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
</TouchableOpacity>
<View className="flex flex-row items-center self-center px-4">
<Text className="text-center font-bold text-2xl mr-2">
{item?.Name}
</Text>
</View>
<View>
<View className="flex flex-row items-center self-center">
<TouchableOpacity onPress={() => {}}>
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
</TouchableOpacity>
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
<Text className="text-center opacity-50">
{`Episode ${item.IndexNumber}`}
</Text>
</View>
</View>
</>
);
};

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
}
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
const command = `-y -fflags +genpts -i ${url} -c copy -bufsize 10M -max_muxing_queue_size 4096 ${output}`;
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
const startRemuxing = useCallback(async () => {
writeToLog(
@@ -54,28 +54,38 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
);
});
await FFmpegKit.executeAsync(command, async (session) => {
const returnCode = await session.getReturnCode();
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
);
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
}
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
);
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
resolve();
}
setProgress(null);
setProgress(null);
} catch (error) {
reject(error);
}
});
});
} catch (error) {
console.error("Failed to remux:", error);
@@ -84,6 +94,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
setProgress(null);
throw error; // Re-throw the error to propagate it to the caller
}
}, [output, item, command, setProgress]);

View File

@@ -16,6 +16,7 @@
},
"dependencies": {
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.2",
"@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.0",

View File

@@ -56,7 +56,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin(
() =>
new Jellyfin({
clientInfo: { name: "Streamyfin", version: "1.0.0" },
clientInfo: { name: "Streamyfin", version: "0.4.2" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
}),
);

View File

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

55
utils/atoms/queue.ts Normal file
View File

@@ -0,0 +1,55 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { atom, useAtom } from "jotai";
import { useEffect } from "react";
export interface Job {
id: string;
item: BaseItemDto;
execute: () => void | Promise<void>;
}
export const queueAtom = atom<Job[]>([]);
export const isProcessingAtom = atom(false);
export const queueActions = {
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
const updatedQueue = [...queue, job];
console.info("Enqueueing job", job, updatedQueue);
setQueue(updatedQueue);
},
processJob: async (
queue: Job[],
setQueue: (update: Job[]) => void,
setProcessing: (processing: boolean) => void,
) => {
const [job, ...rest] = queue;
setQueue(rest);
console.info("Processing job", job);
setProcessing(true);
await job.execute();
console.info("Job done", job);
setProcessing(false);
},
clear: (
setQueue: (update: Job[]) => void,
setProcessing: (processing: boolean) => void,
) => {
setQueue([]);
setProcessing(false);
},
};
export const useJobProcessor = () => {
const [queue, setQueue] = useAtom(queueAtom);
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
useEffect(() => {
console.info("Queue changed", queue, isProcessing);
if (queue.length > 0 && !isProcessing) {
console.info("Processing queue", queue);
queueActions.processJob(queue, setQueue, setProcessing);
}
}, [queue, isProcessing, setQueue, setProcessing]);
};