mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
16 Commits
fix/no-ffm
...
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1368fbd935 | ||
|
|
cb95ccff3a | ||
|
|
d854699cc8 | ||
|
|
49c95a091c | ||
|
|
ed301a9152 | ||
|
|
ecc31c3593 | ||
|
|
b7a9c41a9a | ||
|
|
680838fee1 | ||
|
|
0041aa981b | ||
|
|
8ca9fba583 | ||
|
|
694a5d6d21 | ||
|
|
46ff07a800 | ||
|
|
2fe83b4209 | ||
|
|
b1c6842c8e | ||
|
|
437da25a63 | ||
|
|
03244f318d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,3 +26,4 @@ Streamyfin.app
|
||||
/android
|
||||
|
||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||
credentials.json
|
||||
|
||||
10
README.md
10
README.md
@@ -21,14 +21,20 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
||||
|
||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
|
||||
|
||||
## 🛠️ TestFlight (pending review)
|
||||
## 🛠️ Beta testing (iOS/Android)
|
||||
|
||||
Soon iOS users can test Streamyfin in beta via TestFlight. To join the beta program, click the link below.
|
||||
## TestFlight
|
||||
|
||||
<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
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
|
||||
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
|
||||
</a>
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
42
app.json
42
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Streamyfin",
|
||||
"slug": "streamyfin",
|
||||
"version": "0.0.6",
|
||||
"version": "0.2.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "streamyfin",
|
||||
@@ -15,7 +15,6 @@
|
||||
"jsEngine": "hermes",
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"userInterfaceStyle": "dark",
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.",
|
||||
"NSMicrophoneUsageDescription": "The app needs access to your microphone."
|
||||
@@ -25,12 +24,20 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "jsc",
|
||||
"userInterfaceStyle": "light",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
"androidNavigationBar": {
|
||||
"visible": true,
|
||||
"barStyle": "dark-content",
|
||||
"backgroundColor": "#000000"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin"
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
"package": "com.fredrikburmester.streamyfin",
|
||||
"permissions": [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
|
||||
],
|
||||
"versionCode": 6
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@@ -41,6 +48,13 @@
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"react-native-compressor",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
@@ -48,7 +62,7 @@
|
||||
"androidExtensions": {
|
||||
"useExoplayerRtsp": false,
|
||||
"useExoplayerSmoothStreaming": false,
|
||||
"useExoplayerHls": false,
|
||||
"useExoplayerHls": true,
|
||||
"useExoplayerDash": false
|
||||
}
|
||||
}
|
||||
@@ -56,7 +70,9 @@
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"ios": { "deploymentTarget": "14.0" },
|
||||
"ios": {
|
||||
"deploymentTarget": "14.0"
|
||||
},
|
||||
"android": {
|
||||
"minSdkVersion": 24,
|
||||
"packagingOptions": {
|
||||
@@ -79,6 +95,12 @@
|
||||
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
}
|
||||
},
|
||||
"owner": "fredrikburmester"
|
||||
"owner": "fredrikburmester",
|
||||
"runtimeVersion": {
|
||||
"policy": "appVersion"
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { router, Tabs } from "expo-router";
|
||||
import React from "react";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
|
||||
export default function TabLayout() {
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android") {
|
||||
NavigationBar.setBackgroundColorAsync("#121212");
|
||||
NavigationBar.setBorderColorAsync("#121212");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
|
||||
@@ -241,7 +241,7 @@ export default function index() {
|
||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col py-4 gap-y-4">
|
||||
<View className="flex flex-col pt-4 pb-24 gap-y-4">
|
||||
<View>
|
||||
<Text className="px-4 text-2xl font-bold mb-2">
|
||||
Continue Watching
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function search() {
|
||||
|
||||
return (
|
||||
<ScrollView keyboardDismissMode="on-drag">
|
||||
<View className="flex flex-col py-2">
|
||||
<View className="flex flex-col pt-2 pb-20">
|
||||
<View className="mb-4 px-4">
|
||||
<Input
|
||||
autoCorrect={false}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAtom } from "jotai";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { router } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
const { data: downloadedFiles, isLoading } = useQuery({
|
||||
@@ -88,6 +89,7 @@ const downloads: React.FC = () => {
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
FFmpegKit.cancel();
|
||||
setProcess(null);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
@@ -22,16 +22,29 @@ import { ParallaxScrollView } from "../../../../components/ParallaxPage";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { useCastDevice } from "react-native-google-cast";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios12 from "@/utils/profiles/ios12";
|
||||
import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
const { id } = local as { id: string };
|
||||
|
||||
const [playbackURL, setPlaybackURL] = useState<string | null>(null);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const { data: item, isLoading: l1 } = useQuery({
|
||||
queryKey: ["item", id],
|
||||
queryFn: async () =>
|
||||
@@ -60,6 +73,52 @@ const page: React.FC = () => {
|
||||
[item],
|
||||
);
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", item?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !item?.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: item?.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!item?.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const { data: playbackUrl } = useQuery({
|
||||
queryKey: ["playbackUrl", item?.Id, maxBitrate, castDevice],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id || !sessionData) return null;
|
||||
|
||||
const url = await getStreamUrl({
|
||||
api,
|
||||
userId: user.Id,
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
|
||||
});
|
||||
|
||||
return url;
|
||||
},
|
||||
enabled: !!sessionData,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
const onPressPlay = useCallback(() => {
|
||||
if (!playbackUrl || !item) return;
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
}, [playbackUrl, item]);
|
||||
|
||||
if (l1)
|
||||
return (
|
||||
<View className="justify-center items-center h-full">
|
||||
@@ -151,20 +210,19 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
||||
{playbackURL && (
|
||||
<DownloadItem item={item} playbackURL={playbackURL} />
|
||||
{playbackUrl && (
|
||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
||||
)}
|
||||
// <Chromecast />
|
||||
<Chromecast />
|
||||
</View>
|
||||
<Text>{item.Overview}</Text>
|
||||
</View>
|
||||
<View className="flex flex-col p-4">
|
||||
<VideoPlayer
|
||||
itemId={item.Id}
|
||||
onChangePlaybackURL={(val) => {
|
||||
setPlaybackURL(val);
|
||||
}}
|
||||
<BitrateSelector
|
||||
onChange={(val) => setMaxBitrate(val)}
|
||||
selected={maxBitrate}
|
||||
/>
|
||||
<PlayButton item={item} chromecastReady={false} onPress={onPressPlay} />
|
||||
</View>
|
||||
<ScrollView horizontal className="flex px-4 mb-4">
|
||||
<View className="flex flex-row space-x-2 ">
|
||||
|
||||
@@ -10,12 +10,21 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const params = useLocalSearchParams();
|
||||
const { id: seriesId } = params as { id: string };
|
||||
const { id: seriesId, seasonIndex } = params as {
|
||||
id: string;
|
||||
seasonIndex: string;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (seriesId) {
|
||||
console.log("seasonIndex", seasonIndex);
|
||||
}
|
||||
}, [seriesId]);
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -84,7 +93,7 @@ const page: React.FC = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<View className="flex flex-col pt-4 pb-12">
|
||||
<View className="flex flex-col pt-4 pb-24">
|
||||
<View className="px-4 py-4">
|
||||
<Text className="text-3xl font-bold">{item?.Name}</Text>
|
||||
<Text className="">{item?.Overview}</Text>
|
||||
|
||||
@@ -64,19 +64,17 @@ export default function settings() {
|
||||
<Text className="font-bold text-2xl">Logs</Text>
|
||||
<View className="flex flex-col space-y-2">
|
||||
{logs?.map((log, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className="bg-neutral-800 border border-neutral-900 rounded p-2"
|
||||
>
|
||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
||||
<Text
|
||||
className={`
|
||||
mb-1
|
||||
${log.level === "INFO" && "text-blue-500"}
|
||||
${log.level === "ERROR" && "text-red-500"}
|
||||
`}
|
||||
>
|
||||
{log.level}
|
||||
</Text>
|
||||
<Text>{log.message}</Text>
|
||||
<Text className="text-xs">{log.message}</Text>
|
||||
</View>
|
||||
))}
|
||||
{logs?.length === 0 && (
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { useFonts } from "expo-font";
|
||||
import { router, Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { useEffect, useRef } from "react";
|
||||
import "react-native-reanimated";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useFonts } from "expo-font";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { Stack, router } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Platform, TouchableOpacity } from "react-native";
|
||||
import "react-native-reanimated";
|
||||
|
||||
import Feather from "@expo/vector-icons/Feather";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Video from "react-native-video";
|
||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -39,6 +46,8 @@ export default function RootLayout() {
|
||||
}),
|
||||
);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
@@ -53,9 +62,9 @@ export default function RootLayout() {
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<JotaiProvider>
|
||||
<JellyfinProvider>
|
||||
<StatusBar style="auto" />
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack>
|
||||
<Stack screenOptions={{}}>
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
@@ -68,12 +77,8 @@ export default function RootLayout() {
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
presentation: "modal",
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => router.back()}>
|
||||
<Feather name="x-circle" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -81,14 +86,8 @@ export default function RootLayout() {
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/player/offline/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "transparent" },
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -103,7 +102,7 @@ export default function RootLayout() {
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "transparent" },
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
@@ -120,6 +119,7 @@ export default function RootLayout() {
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</JellyfinProvider>
|
||||
</JotaiProvider>
|
||||
|
||||
164
app/login.tsx
Normal file
164
app/login.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { KeyboardAvoidingView, Platform, View } from "react-native";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const CredentialsSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { setServer, login, removeServer } = useJellyfin();
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
const [serverURL, setServerURL] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<{
|
||||
username: string;
|
||||
password: string;
|
||||
}>({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = CredentialsSchema.safeParse(credentials);
|
||||
if (result.success) {
|
||||
await login(credentials.username, credentials.password);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} 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 });
|
||||
};
|
||||
|
||||
if (api?.basePath) {
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-center h-full gap-y-2">
|
||||
<View>
|
||||
<Text className="text-3xl font-bold">Jellyfin</Text>
|
||||
<Text className="opacity-50 mb-2">Server: {api.basePath}</Text>
|
||||
<Button
|
||||
color="black"
|
||||
onPress={() => {
|
||||
removeServer();
|
||||
setServerURL("");
|
||||
}}
|
||||
justify="between"
|
||||
iconLeft={
|
||||
<Ionicons name="arrow-back-outline" size={18} color={"white"} />
|
||||
}
|
||||
>
|
||||
Change server
|
||||
</Button>
|
||||
</View>
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
value={credentials.username}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="username"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
value={credentials.password}
|
||||
secureTextEntry
|
||||
keyboardType="default"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="password"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={500}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button onPress={handleLogin} loading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<View className="flex flex-col px-4 justify-center h-full">
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<Text className="text-3xl font-bold">Jellyfin</Text>
|
||||
<Text className="opacity-50">Enter a server adress</Text>
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder="http(s)://..."
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
autoFocus
|
||||
secureTextEntry={false}
|
||||
keyboardType="url"
|
||||
returnKeyType="done"
|
||||
autoCapitalize="none"
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Button onPress={() => handleConnect(parsedServerURL)}>
|
||||
Connect
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
BIN
assets/en_badge_web_generic.png
Normal file
BIN
assets/en_badge_web_generic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
assets/images/featured.jpg
Normal file
BIN
assets/images/featured.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/images/icon.jpg
Normal file
BIN
assets/images/icon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
assets/images/icon_512x512.jpg
Normal file
BIN
assets/images/icon_512x512.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
75
components/BitrateSelector.tsx
Normal file
75
components/BitrateSelector.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
|
||||
export type Bitrate = {
|
||||
key: string;
|
||||
value: number | undefined;
|
||||
};
|
||||
|
||||
const BITRATES: Bitrate[] = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onChange: (value: Bitrate) => void;
|
||||
selected: Bitrate;
|
||||
};
|
||||
|
||||
export const BitrateSelector: React.FC<Props> = ({ onChange, selected }) => {
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{BITRATES?.map((b, index: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={index.toString()}
|
||||
onSelect={() => {
|
||||
onChange(b);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
// import React, { useEffect } from "react";
|
||||
// import {
|
||||
// CastButton,
|
||||
// useCastDevice,
|
||||
// useDevices,
|
||||
// useRemoteMediaClient,
|
||||
// } from "react-native-google-cast";
|
||||
// import GoogleCast from "react-native-google-cast";
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
CastButton,
|
||||
useCastDevice,
|
||||
useDevices,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import GoogleCast from "react-native-google-cast";
|
||||
|
||||
type Props = {
|
||||
item?: BaseItemDto | null;
|
||||
@@ -14,22 +14,21 @@ type Props = {
|
||||
};
|
||||
|
||||
export const Chromecast: React.FC<Props> = () => {
|
||||
// const client = useRemoteMediaClient();
|
||||
// const castDevice = useCastDevice();
|
||||
// const devices = useDevices();
|
||||
// const sessionManager = GoogleCast.getSessionManager();
|
||||
// const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
const client = useRemoteMediaClient();
|
||||
const castDevice = useCastDevice();
|
||||
const devices = useDevices();
|
||||
const sessionManager = GoogleCast.getSessionManager();
|
||||
const discoveryManager = GoogleCast.getDiscoveryManager();
|
||||
|
||||
// useEffect(() => {
|
||||
// (async () => {
|
||||
// if (!discoveryManager) {
|
||||
// return;
|
||||
// }
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!discoveryManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
// await discoveryManager.startDiscovery();
|
||||
// })();
|
||||
// }, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
await discoveryManager.startDiscovery();
|
||||
})();
|
||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||
|
||||
// return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
|
||||
return <></>;
|
||||
return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
|
||||
};
|
||||
|
||||
288
components/CurrentlyPlayingBar.tsx
Normal file
288
components/CurrentlyPlayingBar.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
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, 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, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { useRouter, useSegments } from "expo-router";
|
||||
import { BlurView } from "expo-blur";
|
||||
|
||||
export const currentlyPlayingItemAtom = atom<{
|
||||
item: BaseItemDto;
|
||||
playbackUrl: string;
|
||||
} | 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();
|
||||
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const aBottom = useSharedValue(0);
|
||||
const aPadding = useSharedValue(0);
|
||||
const aHeight = useSharedValue(100);
|
||||
const router = useRouter();
|
||||
const animatedOuterStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
bottom: withTiming(aBottom.value, { duration: 500 }),
|
||||
height: withTiming(aHeight.value, { duration: 500 }),
|
||||
padding: withTiming(aPadding.value, { duration: 500 }),
|
||||
};
|
||||
});
|
||||
|
||||
const aPaddingBottom = useSharedValue(30);
|
||||
const aPaddingInner = useSharedValue(12);
|
||||
const aBorderRadiusBottom = useSharedValue(12);
|
||||
const animatedInnerStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
padding: withTiming(aPaddingInner.value, { duration: 500 }),
|
||||
paddingBottom: withTiming(aPaddingBottom.value, { duration: 500 }),
|
||||
borderBottomLeftRadius: withTiming(aBorderRadiusBottom.value, {
|
||||
duration: 500,
|
||||
}),
|
||||
borderBottomRightRadius: withTiming(aBorderRadiusBottom.value, {
|
||||
duration: 500,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (segments.find((s) => s.includes("tabs"))) {
|
||||
// Tab screen - i.e. home
|
||||
aBottom.value = Platform.OS === "ios" ? 78 : 50;
|
||||
aHeight.value = 80;
|
||||
aPadding.value = 8;
|
||||
aPaddingBottom.value = 8;
|
||||
aPaddingInner.value = 8;
|
||||
} else {
|
||||
// Inside a normal screen
|
||||
aBottom.value = Platform.OS === "ios" ? 0 : 0;
|
||||
aHeight.value = Platform.OS === "ios" ? 110 : 80;
|
||||
aPadding.value = Platform.OS === "ios" ? 0 : 8;
|
||||
aPaddingInner.value = Platform.OS === "ios" ? 12 : 8;
|
||||
aPaddingBottom.value = Platform.OS === "ios" ? 40 : 12;
|
||||
}
|
||||
}, [segments]);
|
||||
|
||||
const { data: item } = useQuery({
|
||||
queryKey: ["item", cp?.item.Id],
|
||||
queryFn: async () =>
|
||||
await getUserItemData({
|
||||
api,
|
||||
userId: user?.Id,
|
||||
itemId: cp?.item.Id,
|
||||
}),
|
||||
enabled: !!cp?.item.Id && !!api,
|
||||
staleTime: 60,
|
||||
});
|
||||
|
||||
const { data: sessionData } = useQuery({
|
||||
queryKey: ["sessionData", cp?.item.Id],
|
||||
queryFn: async () => {
|
||||
if (!cp?.item.Id) return null;
|
||||
const playbackData = await getMediaInfoApi(api!).getPlaybackInfo({
|
||||
itemId: cp?.item.Id,
|
||||
userId: user?.Id,
|
||||
});
|
||||
return playbackData.data;
|
||||
},
|
||||
enabled: !!cp?.item.Id && !!api && !!user?.Id,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const onProgress = useCallback(
|
||||
({ currentTime }: OnProgressData) => {
|
||||
if (!currentTime || !sessionData?.PlaySessionId || paused) return;
|
||||
const newProgress = currentTime * 10000000;
|
||||
setProgress(newProgress);
|
||||
reportPlaybackProgress({
|
||||
api,
|
||||
itemId: cp?.item.Id,
|
||||
positionTicks: newProgress,
|
||||
sessionId: sessionData.PlaySessionId,
|
||||
});
|
||||
},
|
||||
[sessionData?.PlaySessionId, item, api, paused],
|
||||
);
|
||||
|
||||
const play = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.resume();
|
||||
setPaused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pause = useCallback(() => {
|
||||
videoRef.current?.pause();
|
||||
setPaused(true);
|
||||
|
||||
if (progress > 0)
|
||||
reportPlaybackStopped({
|
||||
api,
|
||||
itemId: item?.Id,
|
||||
positionTicks: progress,
|
||||
sessionId: sessionData?.PlaySessionId,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["nextUp", item?.SeriesId],
|
||||
refetchType: "all",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["episodes"],
|
||||
refetchType: "all",
|
||||
});
|
||||
}, [api, item, progress, sessionData, queryClient]);
|
||||
|
||||
const startPosition = useMemo(
|
||||
() =>
|
||||
item?.UserData?.PlaybackPositionTicks
|
||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
||||
: 0,
|
||||
[item],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (cp?.playbackUrl) {
|
||||
play();
|
||||
}
|
||||
}, [cp?.playbackUrl]);
|
||||
|
||||
if (!cp) 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="aspect-video h-full bg-neutral-800 rounded-md overflow-hidden"
|
||||
>
|
||||
{cp.playbackUrl && (
|
||||
<Video
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
source={{
|
||||
uri: cp.playbackUrl,
|
||||
isNetwork: true,
|
||||
startPosition,
|
||||
}}
|
||||
controls={false}
|
||||
ref={videoRef}
|
||||
onBuffer={(e) =>
|
||||
e.isBuffering ? console.log("Buffering...") : null
|
||||
}
|
||||
onProgress={(e) => onProgress(e)}
|
||||
paused={paused}
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
play();
|
||||
}}
|
||||
ignoreSilentSwitch="ignore"
|
||||
renderLoader={
|
||||
<View className="flex flex-col items-center justify-center h-full">
|
||||
<ActivityIndicator size={"small"} color={"white"} />
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<View className="shrink text-xs">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/items/${item?.Id}/page`);
|
||||
}}
|
||||
>
|
||||
<Text>{item?.Name}</Text>
|
||||
</TouchableOpacity>
|
||||
{item?.SeriesName ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push(`/(auth)/series/${item.SeriesId}/page`);
|
||||
}}
|
||||
className="text-xs opacity-50"
|
||||
>
|
||||
<Text>{item.SeriesName}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View>
|
||||
<Text className="text-xs opacity-50">
|
||||
{item?.ProductionYear}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
if (paused) play();
|
||||
else pause();
|
||||
}}
|
||||
className="aspect-square rounded flex flex-col items-center justify-center p-2"
|
||||
>
|
||||
{paused ? (
|
||||
<Ionicons name="play" size={24} color="white" />
|
||||
) : (
|
||||
<Ionicons name="pause" size={24} color="white" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setCp(null);
|
||||
}}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -11,16 +11,17 @@ 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;
|
||||
playbackURL: string;
|
||||
playbackUrl: string;
|
||||
};
|
||||
|
||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
item,
|
||||
playbackURL,
|
||||
playbackUrl,
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -29,6 +30,8 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
const { downloadMedia, isDownloading, error, cancelDownload } =
|
||||
useDownloadMedia(api, user?.Id);
|
||||
|
||||
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
||||
|
||||
const { data: playbackInfo, isLoading } = useQuery({
|
||||
queryKey: ["playbackInfo", item.Id],
|
||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
||||
@@ -85,7 +88,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
{process ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// cancelRemuxing();
|
||||
cancelRemuxing();
|
||||
}}
|
||||
className="flex flex-row items-center"
|
||||
>
|
||||
@@ -130,7 +133,7 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
// downloadFile();
|
||||
// startRemuxing();
|
||||
startRemuxing();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
||||
|
||||
@@ -29,15 +29,9 @@ export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
|
||||
uri: url,
|
||||
isNetwork: false,
|
||||
}}
|
||||
controls
|
||||
ref={videoRef}
|
||||
onError={onError}
|
||||
resizeMode="contain"
|
||||
reportBandwidth
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: 16 / 9,
|
||||
}}
|
||||
ignoreSilentSwitch="ignore"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,14 +32,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[2, 1, 1]
|
||||
[2, 1, 1],
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -61,7 +61,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
onPress={() => router.back()}
|
||||
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
|
||||
style={{
|
||||
top: inset.top,
|
||||
top: inset.top + 17,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
|
||||
34
components/PlayButton.tsx
Normal file
34
components/PlayButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
onPress: () => void;
|
||||
chromecastReady: boolean;
|
||||
};
|
||||
|
||||
export const PlayButton: React.FC<Props> = ({
|
||||
item,
|
||||
onPress,
|
||||
chromecastReady,
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
onPress={onPress}
|
||||
iconRight={
|
||||
chromecastReady ? (
|
||||
<Feather name="cast" size={20} color="white" />
|
||||
) : (
|
||||
<Ionicons name="play-circle" size={24} color="white" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -23,31 +23,13 @@ import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
itemId: string;
|
||||
onChangePlaybackURL: (url: string | null) => void;
|
||||
};
|
||||
|
||||
const BITRATES = [
|
||||
{
|
||||
key: "Max",
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
key: "4 Mb/s",
|
||||
value: 4000000,
|
||||
},
|
||||
{
|
||||
key: "2 Mb/s",
|
||||
value: 2000000,
|
||||
},
|
||||
{
|
||||
key: "500 Kb/s",
|
||||
value: 500000,
|
||||
},
|
||||
];
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
itemId,
|
||||
onChangePlaybackURL,
|
||||
@@ -194,6 +176,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
});
|
||||
}, [item, client, playbackURL]);
|
||||
|
||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
useEffect(() => {
|
||||
videoRef.current?.pause();
|
||||
}, []);
|
||||
@@ -263,14 +247,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
<Button
|
||||
disabled={!enableVideo}
|
||||
onPress={() => {
|
||||
if (chromecastReady) {
|
||||
cast();
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!videoRef.current) return;
|
||||
videoRef.current.presentFullscreenPlayer();
|
||||
}, 1000);
|
||||
}
|
||||
// if (chromecastReady) {
|
||||
// cast();
|
||||
// } else {
|
||||
// setTimeout(() => {
|
||||
// if (!videoRef.current) return;
|
||||
// videoRef.current.presentFullscreenPlayer();
|
||||
// }, 1000);
|
||||
// }
|
||||
if (item) setCp(item);
|
||||
}}
|
||||
iconRight={
|
||||
chromecastReady ? (
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { TextInputProps, TextProps } from "react-native";
|
||||
import { TextInput } from "react-native";
|
||||
export function Input(props: TextInputProps) {
|
||||
const { style, ...otherProps } = props;
|
||||
const inputRef = React.useRef<TextInput>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
|
||||
allowFontScaling={false}
|
||||
style={[{ color: "white" }, style]}
|
||||
{...otherProps}
|
||||
placeholderTextColor={"#9CA3AF"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useRef, useMemo, useState } from "react";
|
||||
import Video, { VideoRef } from "react-native-video";
|
||||
import { useCallback } from "react";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
|
||||
|
||||
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
const openFile = () => {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
};
|
||||
|
||||
const fileUrl = useMemo(() => {
|
||||
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
const openFile = useCallback(() => {
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
const options = [
|
||||
@@ -72,26 +70,6 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<Video
|
||||
style={{ width: 0, height: 0 }}
|
||||
source={{
|
||||
uri: fileUrl,
|
||||
isNetwork: false,
|
||||
}}
|
||||
controls
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
setIsPlaying(false);
|
||||
videoRef.current?.pause();
|
||||
}}
|
||||
onFullscreenPlayerDidPresent={() => {
|
||||
setIsPlaying(true);
|
||||
videoRef.current?.resume();
|
||||
}}
|
||||
ref={videoRef}
|
||||
resizeMode="contain"
|
||||
paused={!isPlaying}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,30 +3,22 @@ import { Text } from "../common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { router } from "expo-router";
|
||||
import { useFiles } from "@/hooks/useFiles";
|
||||
import Video, {
|
||||
OnBufferData,
|
||||
OnPlaybackStateChangedData,
|
||||
OnProgressData,
|
||||
OnVideoErrorData,
|
||||
VideoRef,
|
||||
} from "react-native-video";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
|
||||
|
||||
export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
const { deleteFile } = useFiles();
|
||||
const videoRef = useRef<VideoRef | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
|
||||
const openFile = () => {
|
||||
videoRef.current?.presentFullscreenPlayer();
|
||||
};
|
||||
|
||||
const fileUrl = useMemo(() => {
|
||||
return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||
const openFile = useCallback(() => {
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
const options = [
|
||||
@@ -82,26 +74,6 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
))}
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Root>
|
||||
|
||||
<Video
|
||||
style={{ width: 0, height: 0 }}
|
||||
source={{
|
||||
uri: fileUrl,
|
||||
isNetwork: false,
|
||||
}}
|
||||
controls
|
||||
onFullscreenPlayerDidDismiss={() => {
|
||||
setIsPlaying(false);
|
||||
videoRef.current?.pause();
|
||||
}}
|
||||
onFullscreenPlayerDidPresent={() => {
|
||||
setIsPlaying(true);
|
||||
videoRef.current?.resume();
|
||||
}}
|
||||
ref={videoRef}
|
||||
resizeMode="contain"
|
||||
paused={!isPlaying}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
import { Text } from "../common/Text";
|
||||
|
||||
type Props = {
|
||||
item: BaseItemDto;
|
||||
};
|
||||
|
||||
export const seasonIndexAtom = atom<number>(1);
|
||||
|
||||
export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
|
||||
|
||||
const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
|
||||
const [selectedSeasonId, setSelectedSeasonId] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const { data: seasons } = useQuery({
|
||||
queryKey: ["seasons", item.Id],
|
||||
@@ -38,7 +40,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
@@ -46,6 +48,12 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
enabled: !!api && !!user?.Id && !!item.Id,
|
||||
});
|
||||
|
||||
const selectedSeasonId: string | null = useMemo(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
[seasons, seasonIndex],
|
||||
);
|
||||
|
||||
const { data: episodes } = useQuery({
|
||||
queryKey: ["episodes", item.Id, selectedSeasonId],
|
||||
queryFn: async () => {
|
||||
@@ -62,7 +70,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return response.data.Items as BaseItemDto[];
|
||||
@@ -70,22 +78,13 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!seasons || seasons.length === 0) return;
|
||||
|
||||
setSelectedSeasonId(
|
||||
seasons.find((season: any) => season.IndexNumber === 1)?.Id
|
||||
);
|
||||
setSelectedSeason(1);
|
||||
}, [seasons]);
|
||||
|
||||
return (
|
||||
<View className="mb-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-row px-4">
|
||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>Season {selectedSeason}</Text>
|
||||
<Text>Season {seasonIndex}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
@@ -103,8 +102,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
<DropdownMenu.Item
|
||||
key={season.Name}
|
||||
onSelect={() => {
|
||||
setSelectedSeason(season.IndexNumber);
|
||||
setSelectedSeasonId(season.Id);
|
||||
setSeasonIndex(season.IndexNumber);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
||||
|
||||
7
eas.json
7
eas.json
@@ -20,7 +20,12 @@
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {}
|
||||
"production": {
|
||||
"channel": "0.0.6",
|
||||
"android": {
|
||||
"image": "latest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
|
||||
127
hooks/useRemuxHlsToMp4.ts
Normal file
127
hooks/useRemuxHlsToMp4.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
*
|
||||
* @param url - The URL of the HLS stream
|
||||
* @param item - The BaseItemDto object representing the media item
|
||||
* @returns An object with remuxing-related functions
|
||||
*/
|
||||
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
||||
const [_, setProgress] = useAtom(runningProcesses);
|
||||
|
||||
if (!item.Id || !item.Name) {
|
||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
||||
throw new Error("Item must have an Id and Name");
|
||||
}
|
||||
|
||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||
const command = `-y -fflags +genpts -i ${url} -c copy -bufsize 10M -max_muxing_queue_size 4096 ${output}`;
|
||||
|
||||
const startRemuxing = useCallback(async () => {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
|
||||
);
|
||||
|
||||
try {
|
||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
||||
|
||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
||||
const videoLength =
|
||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
||||
const totalFrames = videoLength * fps;
|
||||
const processedFrames = statistics.getVideoFrameNumber();
|
||||
const speed = statistics.getSpeed();
|
||||
|
||||
const percentage =
|
||||
totalFrames > 0
|
||||
? Math.floor((processedFrames / totalFrames) * 100)
|
||||
: 0;
|
||||
|
||||
setProgress((prev) =>
|
||||
prev?.item.Id === item.Id!
|
||||
? { ...prev, progress: percentage, speed }
|
||||
: prev,
|
||||
);
|
||||
});
|
||||
|
||||
await FFmpegKit.executeAsync(command, async (session) => {
|
||||
const returnCode = await session.getReturnCode();
|
||||
|
||||
if (returnCode.isValueSuccess()) {
|
||||
await updateDownloadedFiles(item);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
||||
);
|
||||
} else if (returnCode.isValueError()) {
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
);
|
||||
} else if (returnCode.isValueCancel()) {
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
||||
);
|
||||
}
|
||||
|
||||
setProgress(null);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to remux:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||
);
|
||||
setProgress(null);
|
||||
}
|
||||
}, [output, item, command, setProgress]);
|
||||
|
||||
const cancelRemuxing = useCallback(() => {
|
||||
FFmpegKit.cancel();
|
||||
setProgress(null);
|
||||
writeToLog(
|
||||
"INFO",
|
||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
|
||||
);
|
||||
}, [item.Name, setProgress]);
|
||||
|
||||
return { startRemuxing, cancelRemuxing };
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the list of downloaded files in AsyncStorage.
|
||||
*
|
||||
* @param item - The item to add to the downloaded files list
|
||||
*/
|
||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
||||
try {
|
||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||
);
|
||||
const updatedFiles = [
|
||||
...currentFiles.filter((i) => i.Id !== item.Id),
|
||||
item,
|
||||
];
|
||||
await AsyncStorage.setItem(
|
||||
"downloaded_files",
|
||||
JSON.stringify(updatedFiles),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error updating downloaded files:", error);
|
||||
writeToLog(
|
||||
"ERROR",
|
||||
`Failed to update downloaded files for item: ${item.Name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"preset": "jest-expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@jellyfin/sdk": "^0.10.0",
|
||||
"@kesha-antonov/react-native-background-downloader": "^3.2.0",
|
||||
@@ -25,6 +26,7 @@
|
||||
"@tanstack/react-query": "^5.51.16",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"expo": "~51.0.26",
|
||||
"expo-blur": "~13.0.2",
|
||||
"expo-build-properties": "~0.12.5",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-dev-client": "~4.0.22",
|
||||
@@ -34,11 +36,14 @@
|
||||
"expo-image": "~1.12.13",
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"expo-router": "~3.5.21",
|
||||
"expo-splash-screen": "~0.27.5",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "~3.0.7",
|
||||
"expo-updates": "~0.25.22",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"jotai": "^2.9.1",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.2.0",
|
||||
@@ -48,6 +53,7 @@
|
||||
"react-native-compressor": "^1.8.25",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.2",
|
||||
"react-native-ios-context-menu": "^2.5.1",
|
||||
"react-native-ios-utilities": "^4.4.5",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
|
||||
Reference in New Issue
Block a user