diff --git a/README.md b/README.md index 0b08e9f3..0c183897 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox. +
+ + + + + +
+ ## 🌟 Features - 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library. diff --git a/app/(auth)/downloads.tsx b/app/(auth)/downloads.tsx index a4ec967b..53805108 100644 --- a/app/(auth)/downloads.tsx +++ b/app/(auth)/downloads.tsx @@ -6,11 +6,11 @@ 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 { useEffect, useMemo } from "react"; -import { ScrollView, View } from "react-native"; +import { ActivityIndicator, ScrollView, View } from "react-native"; import * as FileSystem from "expo-file-system"; const downloads: React.FC = () => { - const { data: downloadedFiles } = useQuery({ + const { data: downloadedFiles, isLoading } = useQuery({ queryKey: ["downloaded_files"], queryFn: async () => JSON.parse( @@ -19,7 +19,7 @@ const downloads: React.FC = () => { }); const movies = useMemo( - () => downloadedFiles?.filter((f) => f.Type === "Movie"), + () => downloadedFiles?.filter((f) => f.Type === "Movie") || [], [downloadedFiles] ); @@ -43,40 +43,45 @@ const downloads: React.FC = () => { ); }, [downloadedFiles]); - useEffect(() => { - // Get all files from FileStorage - // const filename = `${itemId}.mp4`; - // const fileUri = `${FileSystem.documentDirectory}`; - (async () => { - if (!FileSystem.documentDirectory) return; - const f = await FileSystem.readDirectoryAsync( - FileSystem.documentDirectory - ); - console.log("files", FileSystem.documentDirectory, f); - })(); - }, []); + if (isLoading) { + return ( + + + + ); + } + + if (downloadedFiles?.length === 0) { + return ( + + + No downloaded files + + + ); + } return ( - - - Movies - - {movies?.length} + {movies.length > 0 && ( + + + Movies + + {movies?.length} + + {movies?.map((item: BaseItemDto) => ( + + + + ))} - {movies?.map((item: BaseItemDto) => ( - - - - ))} - - - {groupedBySeries?.map((items: BaseItemDto[], index: number) => ( - - ))} - + )} + {groupedBySeries?.map((items: BaseItemDto[], index: number) => ( + + ))} ); diff --git a/app/(auth)/items/[id]/page.tsx b/app/(auth)/items/[id]/page.tsx index 61c49f62..94c55938 100644 --- a/app/(auth)/items/[id]/page.tsx +++ b/app/(auth)/items/[id]/page.tsx @@ -16,7 +16,7 @@ import { useQuery } from "@tanstack/react-query"; import { Image } from "expo-image"; import { router, useLocalSearchParams } from "expo-router"; import { useAtom } from "jotai"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { ActivityIndicator, ScrollView, @@ -29,6 +29,8 @@ const page: React.FC = () => { const local = useLocalSearchParams(); const { id } = local as { id: string }; + const [playbackURL, setPlaybackURL] = useState(null); + const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); @@ -148,13 +150,20 @@ const page: React.FC = () => { - + {playbackURL && ( + + )} {item.Overview} - + { + setPlaybackURL(val); + }} + /> diff --git a/app/(auth)/settings.tsx b/app/(auth)/settings.tsx index 3d28f5d1..b970cdd8 100644 --- a/app/(auth)/settings.tsx +++ b/app/(auth)/settings.tsx @@ -1,99 +1,22 @@ import { Button } from "@/components/Button"; import { Text } from "@/components/common/Text"; import { ListItem } from "@/components/ListItem"; -import ProgressCircle from "@/components/ProgressCircle"; import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider"; -import { runningProcesses } from "@/utils/atoms/downloads"; +import { useFiles } from "@/utils/files/useFiles"; import { readFromLog } from "@/utils/log"; -import { Ionicons } from "@expo/vector-icons"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery } from "@tanstack/react-query"; import * as FileSystem from "expo-file-system"; -import { useRouter } from "expo-router"; -import { FFmpegKit } from "ffmpeg-kit-react-native"; import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; -import { ScrollView, TouchableOpacity, View } from "react-native"; - -const deleteAllFiles = async () => { - const directoryUri = FileSystem.documentDirectory; - - try { - const fileNames = await FileSystem.readDirectoryAsync(directoryUri!); - for (let item of fileNames) { - await FileSystem.deleteAsync(`${directoryUri}/${item}`); - } - - AsyncStorage.removeItem("downloaded_files"); - } catch (error) { - console.error("Failed to delete the directory:", error); - } -}; - -const deleteFile = async (id: string | null | undefined) => { - if (!id) return; - - try { - FileSystem.deleteAsync(`${FileSystem.documentDirectory}/${id}.mp4`).catch( - (err) => console.error(err) - ); - - const currentFiles = JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) ?? "[]" - ) as BaseItemDto[]; - const updatedFiles = currentFiles.filter((f) => f.Id !== id); - await AsyncStorage.setItem( - "downloaded_files", - JSON.stringify(updatedFiles) - ); - } catch (error) { - console.error(error); - } -}; - -const listDownloadedFiles = async () => { - const directoryUri = FileSystem.documentDirectory; // Directory where files are stored - - try { - const fileNames = await FileSystem.readDirectoryAsync(directoryUri!); - return fileNames; // This will be an array of file names in the directory - } catch (error) { - console.error("Failed to read the directory:", error); - return []; - } -}; +import { ScrollView, View } from "react-native"; +import * as Haptics from "expo-haptics"; export default function settings() { const { logout } = useJellyfin(); + const { deleteAllFiles } = useFiles(); const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); - const [files, setFiles] = useState([]); - const [key, setKey] = useState(0); - - const [session, setSession] = useAtom(runningProcesses); - - const router = useRouter(); - - const [activeProcess] = useAtom(runningProcesses); - - useEffect(() => { - (async () => { - const data = JSON.parse( - (await AsyncStorage.getItem("downloaded_files")) || "[]" - ) as BaseItemDto[]; - - console.log( - "Files", - data.map((i) => i.Name) - ); - - setFiles(data); - })(); - }, [key]); - const { data: logs } = useQuery({ queryKey: ["logs"], queryFn: async () => readFromLog(), @@ -111,99 +34,34 @@ export default function settings() { - - Downloads - - {files.length > 0 ? ( - - {files.map((file) => ( - { - router.back(); - router.push( - `/(auth)/player/offline/page?url=${file.Id}.mp4&itemId=${file.Id}` - ); - }} - > - { - await deleteFile(file.Id); - setKey((prevKey) => prevKey + 1); - }} - > - - - } - /> - - ))} - - ) : activeProcess ? ( - - - } - /> - - ) : ( - No downloaded files - )} - - - - - {session?.item.Id && ( + - )} + Logs - {logs?.map((l) => ( - + {logs?.map((log, index) => ( + - {l.level} + {log.level} - {l.message} + {log.message} ))} diff --git a/app/_layout.tsx b/app/_layout.tsx index 670e3d87..da82317c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -82,12 +82,6 @@ export default function RootLayout() { options={{ headerShown: true, title: "Downloads", - presentation: "modal", - headerLeft: () => ( - router.back()}> - - - ), }} /> @@ -116,6 +111,7 @@ export default function RootLayout() { diff --git a/assets/images/screenshots/1.jpg b/assets/images/screenshots/1.jpg new file mode 100644 index 00000000..67971fc3 Binary files /dev/null and b/assets/images/screenshots/1.jpg differ diff --git a/assets/images/screenshots/2.jpg b/assets/images/screenshots/2.jpg new file mode 100644 index 00000000..66857460 Binary files /dev/null and b/assets/images/screenshots/2.jpg differ diff --git a/assets/images/screenshots/3.jpg b/assets/images/screenshots/3.jpg new file mode 100644 index 00000000..7e745836 Binary files /dev/null and b/assets/images/screenshots/3.jpg differ diff --git a/assets/images/screenshots/4.jpg b/assets/images/screenshots/4.jpg new file mode 100644 index 00000000..9e277fc0 Binary files /dev/null and b/assets/images/screenshots/4.jpg differ diff --git a/assets/images/screenshots/5.jpg b/assets/images/screenshots/5.jpg new file mode 100644 index 00000000..637554c2 Binary files /dev/null and b/assets/images/screenshots/5.jpg differ diff --git a/assets/images/screenshots/6.jpg b/assets/images/screenshots/6.jpg new file mode 100644 index 00000000..c6971ad9 Binary files /dev/null and b/assets/images/screenshots/6.jpg differ diff --git a/assets/images/screenshots/7.jpg b/assets/images/screenshots/7.jpg new file mode 100644 index 00000000..553ba6aa Binary files /dev/null and b/assets/images/screenshots/7.jpg differ diff --git a/assets/images/screenshots/8.jpg b/assets/images/screenshots/8.jpg new file mode 100644 index 00000000..efb8b071 Binary files /dev/null and b/assets/images/screenshots/8.jpg differ diff --git a/components/Button.tsx b/components/Button.tsx index 89f35ba3..616e6764 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -32,7 +32,7 @@ export const Button: React.FC> = ({ case "purple": return "bg-purple-600 active:bg-purple-700"; case "red": - return "bg-red-500"; + return "bg-red-600"; case "black": return "bg-black border border-neutral-900"; } diff --git a/components/DownloadItem.tsx b/components/DownloadItem.tsx index 6a34a2ab..40dc9159 100644 --- a/components/DownloadItem.tsx +++ b/components/DownloadItem.tsx @@ -1,8 +1,12 @@ -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { runningProcesses } from "@/utils/atoms/downloads"; -import { getPlaybackInfo, useDownloadMedia } from "@/utils/jellyfin"; +import { + getPlaybackInfo, + useDownloadMedia, + useRemuxHlsToMp4, +} from "@/utils/jellyfin"; 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"; @@ -14,9 +18,13 @@ import { Text } from "./common/Text"; type DownloadProps = { item: BaseItemDto; + playbackURL: string; }; -export const DownloadItem: React.FC = ({ item }) => { +export const DownloadItem: React.FC = ({ + item, + playbackURL, +}) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const [process] = useAtom(runningProcesses); @@ -24,6 +32,8 @@ export const DownloadItem: React.FC = ({ item }) => { 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), @@ -34,6 +44,8 @@ export const DownloadItem: React.FC = ({ item }) => { const source = playbackInfo.MediaSources?.[0]; + console.log("Source:", JSON.stringify(source)); + if (source?.SupportsDirectPlay && item.CanDownload) { downloadMedia(item); } else { @@ -80,22 +92,34 @@ export const DownloadItem: React.FC = ({ item }) => { {process ? ( { - cancelDownload(); + cancelRemuxing(); }} - className="relative" + className="flex flex-row items-center" > - - - - - {process.progress.toFixed(0)}% + + + + + {process.progress > 0 ? ( + + + {process.progress.toFixed(0)}% + + + ) : null} + + {process?.speed && (process?.speed || 0) > 0 ? ( + + {process.speed.toFixed(2)}x + + ) : null} ) : downloaded ? ( = ({ item }) => { ) : ( { - downloadFile(); + // downloadFile(); + startRemuxing(); }} > diff --git a/components/VideoPlayer.tsx b/components/VideoPlayer.tsx index e6ecb899..675c4fa9 100644 --- a/components/VideoPlayer.tsx +++ b/components/VideoPlayer.tsx @@ -1,4 +1,9 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { + chromecastProfile, + iosProfile, + iOSProfile_2, +} from "@/utils/device-profiles"; import { getStreamUrl, getUserItemData, @@ -17,7 +22,8 @@ import React, { useRef, useState, } from "react"; -import { ActivityIndicator, TouchableOpacity, View } from "react-native"; +import { TouchableOpacity, View } from "react-native"; +import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast"; import Video, { OnBufferData, OnPlaybackStateChangedData, @@ -28,13 +34,12 @@ import Video, { import * as DropdownMenu from "zeego/dropdown-menu"; import { Button } from "./Button"; import { Text } from "./common/Text"; -import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast"; -import GoogleCast from "react-native-google-cast"; -import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; -import { chromecastProfile, iosProfile } from "@/utils/device-profiles"; +import iosFmp4 from "../utils/profiles/iosFmp4"; +import ios12 from "../utils/profiles/ios12"; type VideoPlayerProps = { itemId: string; + onChangePlaybackURL: (url: string | null) => void; }; const BITRATES = [ @@ -56,7 +61,10 @@ const BITRATES = [ }, ]; -export const VideoPlayer: React.FC = ({ itemId }) => { +export const VideoPlayer: React.FC = ({ + itemId, + onChangePlaybackURL, +}) => { const videoRef = useRef(null); const [maxBitrate, setMaxbitrate] = useState(undefined); const [paused, setPaused] = useState(true); @@ -108,11 +116,13 @@ export const VideoPlayer: React.FC = ({ itemId }) => { startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0, maxStreamingBitrate: maxBitrate, sessionData, - deviceProfile: castDevice?.deviceId ? chromecastProfile : iosProfile, + deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12, }); console.log("Transcode URL:", url); + onChangePlaybackURL(url); + return url; }, enabled: !!sessionData, @@ -165,6 +175,8 @@ export const VideoPlayer: React.FC = ({ itemId }) => { return Math.round((item?.UserData?.PlaybackPositionTicks || 0) / 10000); }, [item]); + const [hidePlayer, setHidePlayer] = useState(true); + const enableVideo = useMemo(() => { return ( playbackURL !== undefined && @@ -220,13 +232,11 @@ export const VideoPlayer: React.FC = ({ itemId }) => { return ( - {enableVideo === true && - playbackURL !== null && - playbackURL !== undefined ? ( + {enableVideo === true ? (