Compare commits

..

2 Commits

Author SHA1 Message Date
Fredrik Burmester
6fb8a5fca2 chore 2024-08-11 10:39:57 +02:00
Fredrik Burmester
86dfcc6b7f chore 2024-08-11 09:59:55 +02:00
58 changed files with 536 additions and 2282 deletions

View File

@@ -1,26 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone15Pro]
- OS: [e.g. iOS18]
- Version [e.g. 0.3.1]

View File

@@ -1,14 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

3
.gitignore vendored
View File

@@ -26,6 +26,3 @@ Streamyfin.app
/android /android
pc-api-7079014811501811218-719-3b9f15aeccf8.json pc-api-7079014811501811218-719-3b9f15aeccf8.json
credentials.json
*.apk
*.ipa

View File

@@ -12,40 +12,23 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
## 🌟 Features ## 🌟 Features
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more. - 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone. - 📱 Native video player: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
- 🔊 **Background audio**: Stream music in the background, even when locking the phone. - 📥 Download media (Experimental): Save your media locally and watch it offline.
- 📥 **Download media** (Experimental): Save your media locally and watch it offline. - 📡 Chromecast media (Experimental): Cast your media to any Chromecast-enabled device.
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
## 🧪 Experimental Features ## 🧪 Experimental Features
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. 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.
### Downloading ## 🛠️ TestFlight (pending review)
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. Soon iOS users can test Streamyfin in beta via TestFlight. To join the beta program, click the link below.
## Get it now
<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"> <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"/> <img height=75 alt="Get the beta on TestFlight" src="./assets/Get_the_beta_on_Testflight.svg"/>
</a> </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 ## 🚀 Getting Started
### Prerequisites ### Prerequisites
@@ -104,11 +87,6 @@ If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here. - GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com) - Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
-
## Support
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
## 📝 Credits ## 📝 Credits

View File

@@ -2,48 +2,35 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.4.2", "version": "0.0.6",
"orientation": "default", "orientation": "portrait",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"splash": { "splash": {
"image": "./assets/images/splash.png", "image": "./assets/images/splash.png",
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#29164B" "backgroundColor": "#ffffff"
}, },
"jsEngine": "hermes", "jsEngine": "hermes",
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"requireFullScreen": true, "userInterfaceStyle": "dark",
"infoPlist": { "infoPlist": {
"NSCameraUsageDescription": "The app needs access to your camera to scan barcodes.", "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, "supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin" "bundleIdentifier": "com.fredrikburmester.streamyfin"
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "jsc",
"versionCode": 15, "userInterfaceStyle": "light",
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon.png" "foregroundImage": "./assets/images/icon.png",
"backgroundColor": "#ffffff"
}, },
"package": "com.fredrikburmester.streamyfin", "package": "com.fredrikburmester.streamyfin"
"permissions": [
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"
]
}, },
"web": { "web": {
"bundler": "metro", "bundler": "metro",
@@ -54,22 +41,14 @@
"expo-router", "expo-router",
"expo-font", "expo-font",
"react-native-compressor", "react-native-compressor",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
[ [
"react-native-video", "react-native-video",
{ {
"enableNotificationControls": true, "enableNotificationControls": true,
"enableBackgroundAudio": true,
"androidExtensions": { "androidExtensions": {
"useExoplayerRtsp": false, "useExoplayerRtsp": false,
"useExoplayerSmoothStreaming": false, "useExoplayerSmoothStreaming": false,
"useExoplayerHls": true, "useExoplayerHls": false,
"useExoplayerDash": false "useExoplayerDash": false
} }
} }
@@ -77,12 +56,9 @@
[ [
"expo-build-properties", "expo-build-properties",
{ {
"ios": { "ios": { "deploymentTarget": "14.0" },
"deploymentTarget": "14.0"
},
"android": { "android": {
"minSdkVersion": 24, "minSdkVersion": 24,
"usesCleartextTraffic": true,
"packagingOptions": { "packagingOptions": {
"jniLibs": { "jniLibs": {
"useLegacyPackaging": true "useLegacyPackaging": true
@@ -90,18 +66,6 @@
} }
} }
} }
],
[
"expo-screen-orientation",
{
"initialOrientation": "DEFAULT"
}
],
[
"expo-sensors",
{
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
}
] ]
], ],
"experiments": { "experiments": {
@@ -115,12 +79,6 @@
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68" "projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
} }
}, },
"owner": "fredrikburmester", "owner": "fredrikburmester"
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}
} }
} }

View File

@@ -1,20 +1,12 @@
import { router, Tabs } from "expo-router"; import { router, Tabs } from "expo-router";
import React, { useEffect } from "react"; import React from "react";
import * as NavigationBar from "expo-navigation-bar";
import { TabBarIcon } from "@/components/navigation/TabBarIcon"; import { TabBarIcon } from "@/components/navigation/TabBarIcon";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { Platform, TouchableOpacity, View } from "react-native"; import { TouchableOpacity } from "react-native";
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { Chromecast } from "@/components/Chromecast";
export default function TabLayout() { export default function TabLayout() {
useEffect(() => {
if (Platform.OS === "android") {
NavigationBar.setBackgroundColorAsync("#121212");
NavigationBar.setBorderColorAsync("#121212");
}
}, []);
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{
@@ -42,23 +34,18 @@ export default function TabLayout() {
router.push("/(auth)/downloads"); router.push("/(auth)/downloads");
}} }}
> >
<Feather name="download" color={"white"} size={22} /> <Feather name="download" color={"white"} size={24} />
</TouchableOpacity> </TouchableOpacity>
), ),
headerRight: () => ( headerRight: () => (
<View className="flex flex-row items-center space-x-2"> <TouchableOpacity
<Chromecast /> style={{ marginHorizontal: 17 }}
<TouchableOpacity onPress={() => {
style={{ marginRight: 17 }} router.push("/(auth)/settings");
onPress={() => { }}
router.push("/(auth)/settings"); >
}} <Feather name="settings" color={"white"} size={24} />
> </TouchableOpacity>
<View className="h-10 aspect-square flex items-center justify-center rounded">
<Feather name="settings" color={"white"} size={22} />
</View>
</TouchableOpacity>
</View>
), ),
}} }}
/> />

View File

@@ -241,7 +241,7 @@ export default function index() {
<RefreshControl refreshing={loading} onRefresh={refetch} /> <RefreshControl refreshing={loading} onRefresh={refetch} />
} }
> >
<View className="flex flex-col pt-4 pb-24 gap-y-4"> <View className="flex flex-col py-4 gap-y-4">
<View> <View>
<Text className="px-4 text-2xl font-bold mb-2"> <Text className="px-4 text-2xl font-bold mb-2">
Continue Watching Continue Watching

View File

@@ -68,7 +68,7 @@ export default function search() {
return ( return (
<ScrollView keyboardDismissMode="on-drag"> <ScrollView keyboardDismissMode="on-drag">
<View className="flex flex-col pt-2 pb-20"> <View className="flex flex-col py-2">
<View className="mb-4 px-4"> <View className="mb-4 px-4">
<Input <Input
autoCorrect={false} autoCorrect={false}

View File

@@ -3,15 +3,12 @@ import { Loading } from "@/components/Loading";
import MoviePoster from "@/components/MoviePoster"; import MoviePoster from "@/components/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
BaseItemDto,
ItemSortBy,
} from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
ScrollView, ScrollView,
@@ -26,21 +23,16 @@ const page: React.FC = () => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
useEffect(() => {
console.log("CollectionId", collectionId);
}, [collectionId]);
const { data: collection } = useQuery({ const { data: collection } = useQuery({
queryKey: ["collection", collectionId], queryKey: ["collection", collectionId],
queryFn: async () => { queryFn: async () =>
if (!api) return null; (api &&
const response = await getItemsApi(api).getItems({ (
userId: user?.Id, await getItemsApi(api).getItems({
ids: [collectionId], userId: user?.Id,
}); })
const data = response.data.Items?.[0]; ).data.Items?.find((item) => item.Id == collectionId)) ||
return data; null,
},
enabled: !!api && !!user?.Id, enabled: !!api && !!user?.Id,
staleTime: 0, staleTime: 0,
}); });
@@ -53,84 +45,40 @@ const page: React.FC = () => {
}>({ }>({
queryKey: ["collection-items", collectionId, startIndex], queryKey: ["collection-items", collectionId, startIndex],
queryFn: async () => { queryFn: async () => {
if (!api || !collectionId) if (!api) return [];
return {
Items: [],
TotalRecordCount: 0,
};
const sortBy: ItemSortBy[] = []; const response = await api.axiosInstance.get(
`${api.basePath}/Users/${user?.Id}/Items`,
{
params: {
SortBy:
collection?.CollectionType === "movies"
? "SortName,ProductionYear"
: "SortName",
SortOrder: "Ascending",
IncludeItemTypes:
collection?.CollectionType === "movies" ? "Movie" : "Series",
Recursive: true,
Fields:
collection?.CollectionType === "movies"
? "PrimaryImageAspectRatio,MediaSourceCount"
: "PrimaryImageAspectRatio",
ImageTypeLimit: 1,
EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
ParentId: collectionId,
Limit: 100,
StartIndex: startIndex,
},
headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
},
},
);
switch (collection?.CollectionType) { return response.data || [];
case "movies":
sortBy.push("SortName", "ProductionYear");
break;
case "boxsets":
sortBy.push("IsFolder", "SortName");
break;
default:
sortBy.push("SortName");
break;
}
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: collectionId,
limit: 100,
startIndex,
sortBy,
sortOrder: ["Ascending"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
}, },
enabled: !!collectionId && !!api, enabled: !!collection && !!api,
}); });
// const { data, isLoading, isError } = useQuery<{
// Items: BaseItemDto[];
// TotalRecordCount: number;
// }>({
// queryKey: ["collection-items", collectionId, startIndex],
// queryFn: async () => {
// if (!api) return [];
// const response = await api.axiosInstance.get(
// `${api.basePath}/Users/${user?.Id}/Items`,
// {
// params: {
// SortBy:
// collection?.CollectionType === "movies"
// ? "SortName,ProductionYear"
// : "SortName",
// SortOrder: "Ascending",
// IncludeItemTypes:
// collection?.CollectionType === "movies" ? "Movie" : "Series",
// Recursive: true,
// Fields:
// collection?.CollectionType === "movies"
// ? "PrimaryImageAspectRatio,MediaSourceCount"
// : "PrimaryImageAspectRatio",
// ImageTypeLimit: 1,
// EnableImageTypes: "Primary,Backdrop,Banner,Thumb",
// ParentId: collectionId,
// Limit: 100,
// StartIndex: startIndex,
// },
// headers: {
// Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
// },
// },
// );
// return response.data || [];
// },
// enabled: !!collection && !!api,
// });
const totalItems = useMemo(() => { const totalItems = useMemo(() => {
return data?.TotalRecordCount; return data?.TotalRecordCount;
@@ -143,8 +91,7 @@ const page: React.FC = () => {
<Text className="font-bold text-3xl mb-2">{collection?.Name}</Text> <Text className="font-bold text-3xl mb-2">{collection?.Name}</Text>
<View className="flex flex-row items-center justify-between"> <View className="flex flex-row items-center justify-between">
<Text> <Text>
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "} {startIndex + 1}-{startIndex + 100} of {totalItems}
{totalItems}
</Text> </Text>
<View className="flex flex-row items-center space-x-2"> <View className="flex flex-row items-center space-x-2">
<TouchableOpacity <TouchableOpacity
@@ -178,7 +125,7 @@ const page: React.FC = () => {
</View> </View>
) : ( ) : (
<View className="flex flex-row flex-wrap"> <View className="flex flex-row flex-wrap">
{data?.Items?.map((item: BaseItemDto, index: number) => ( {data?.Items?.map((item: any, index: number) => (
<TouchableOpacity <TouchableOpacity
style={{ style={{
maxWidth: "33%", maxWidth: "33%",
@@ -187,12 +134,10 @@ const page: React.FC = () => {
}} }}
key={index} key={index}
onPress={() => { onPress={() => {
if (item?.Type === "Series") { if (collection?.CollectionType === "movies") {
router.push(`/series/${item.Id}/page`);
} else if (item.IsFolder) {
router.push(`/collections/${item?.Id}/page`);
} else {
router.push(`/items/${item.Id}/page`); router.push(`/items/${item.Id}/page`);
} else if (collection?.CollectionType === "tvshows") {
router.push(`/series/${item.Id}/page`);
} }
}} }}
> >

View File

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

View File

@@ -1,58 +1,37 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { DownloadItem } from "@/components/DownloadItem"; import { DownloadItem } from "@/components/DownloadItem";
import { PlayedStatus } from "@/components/PlayedStatus"; import { PlayedStatus } from "@/components/PlayedStatus";
import { CastAndCrew } from "@/components/series/CastAndCrew"; import { CastAndCrew } from "@/components/series/CastAndCrew";
import { CurrentSeries } from "@/components/series/CurrentSeries"; import { CurrentSeries } from "@/components/series/CurrentSeries";
import { SimilarItems } from "@/components/SimilarItems"; import { SimilarItems } from "@/components/SimilarItems";
import { VideoPlayer } from "@/components/VideoPlayer";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native"; import {
ActivityIndicator,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { ParallaxScrollView } from "../../../../components/ParallaxPage"; import { ParallaxScrollView } from "../../../../components/ParallaxPage";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; 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 CastContext, {
PlayServicesState,
useCastDevice,
useRemoteMediaClient,
} from "react-native-google-cast";
import { chromecastProfile } from "@/utils/profiles/chromecast";
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 page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { id } = local as { id: string }; const { id } = local as { id: string };
const [playbackURL, setPlaybackURL] = useState<string | null>(null);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const castDevice = useCastDevice();
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const [selectedSubtitleStream, setSelectedSubtitleStream] =
useState<number>(0);
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
key: "Max",
value: undefined,
});
const { data: item, isLoading: l1 } = useQuery({ const { data: item, isLoading: l1 } = useQuery({
queryKey: ["item", id], queryKey: ["item", id],
queryFn: async () => queryFn: async () =>
@@ -81,89 +60,6 @@ const page: React.FC = () => {
[item], [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,
selectedAudioStream,
selectedSubtitleStream,
],
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,
audioStreamIndex: selectedAudioStream,
subtitleStreamIndex: selectedSubtitleStream,
});
console.log("Transcode URL: ", url);
return url;
},
enabled: !!sessionData,
staleTime: 0,
});
const [, setCp] = useAtom(currentlyPlayingItemAtom);
const client = useRemoteMediaClient();
const onPressPlay = useCallback(
async (type: "device" | "cast" = "device") => {
if (!playbackUrl || !item) return;
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],
);
if (l1) if (l1)
return ( return (
<View className="justify-center items-center h-full"> <View className="justify-center items-center h-full">
@@ -203,57 +99,72 @@ const page: React.FC = () => {
</> </>
} }
> >
<View className="flex flex-col px-4 pt-4"> <View className="flex flex-col px-4 mb-4 pt-4">
<View className="flex flex-col"> <View className="flex flex-col">
{item.Type === "Episode" ? ( {item.Type === "Episode" ? (
<SeriesTitleHeader item={item} /> <>
<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>
</>
) : ( ) : (
<> <>
<MoviesTitleHeader 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>
</> </>
)} )}
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
<Ratings item={item} />
</View> </View>
<View className="flex flex-row justify-between items-center w-full my-4"> <View className="flex flex-row justify-between items-center w-full my-4">
{playbackUrl ? ( {playbackURL && (
<DownloadItem item={item} playbackUrl={playbackUrl} /> <DownloadItem item={item} playbackURL={playbackURL} />
) : (
<View className="h-12 aspect-square flex items-center justify-center"></View>
)} )}
<PlayedStatus item={item} /> // <Chromecast />
</View> </View>
<Text>{item.Overview}</Text>
<OverviewText text={item.Overview} />
</View> </View>
<View className="flex flex-col p-4 w-full"> <View className="flex flex-col p-4">
<View className="flex flex-row items-center space-x-2 w-full"> <VideoPlayer
<BitrateSelector itemId={item.Id}
onChange={(val) => setMaxBitrate(val)} onChangePlaybackURL={(val) => {
selected={maxBitrate} setPlaybackURL(val);
/> }}
<AudioTrackSelector />
item={item}
onChange={setSelectedAudioStream}
selected={selectedAudioStream}
/>
<SubtitleTrackSelector
item={item}
onChange={setSelectedSubtitleStream}
selected={selectedSubtitleStream}
/>
</View>
<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> </View>
<ScrollView horizontal className="flex px-4 mb-4"> <ScrollView horizontal className="flex px-4 mb-4">
<View className="flex flex-row space-x-2 "> <View className="flex flex-row space-x-2 ">

View File

@@ -10,21 +10,12 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
const page: React.FC = () => { const page: React.FC = () => {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as { const { id: seriesId } = params as { id: string };
id: string;
seasonIndex: string;
};
useEffect(() => {
if (seriesId) {
console.log("seasonIndex", seasonIndex);
}
}, [seriesId]);
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -38,7 +29,7 @@ const page: React.FC = () => {
itemId: seriesId, itemId: seriesId,
}), }),
enabled: !!seriesId && !!api, enabled: !!seriesId && !!api,
staleTime: 60, staleTime: 0,
}); });
const backdropUrl = useMemo( const backdropUrl = useMemo(
@@ -93,7 +84,7 @@ const page: React.FC = () => {
</> </>
} }
> >
<View className="flex flex-col pt-4 pb-24"> <View className="flex flex-col pt-4 pb-12">
<View className="px-4 py-4"> <View className="px-4 py-4">
<Text className="text-3xl font-bold">{item?.Name}</Text> <Text className="text-3xl font-bold">{item?.Name}</Text>
<Text className="">{item?.Overview}</Text> <Text className="">{item?.Overview}</Text>

View File

@@ -64,17 +64,19 @@ export default function settings() {
<Text className="font-bold text-2xl">Logs</Text> <Text className="font-bold text-2xl">Logs</Text>
<View className="flex flex-col space-y-2"> <View className="flex flex-col space-y-2">
{logs?.map((log, index) => ( {logs?.map((log, index) => (
<View key={index} className="bg-neutral-900 rounded-xl p-3"> <View
key={index}
className="bg-neutral-800 border border-neutral-900 rounded p-2"
>
<Text <Text
className={` className={`
mb-1
${log.level === "INFO" && "text-blue-500"} ${log.level === "INFO" && "text-blue-500"}
${log.level === "ERROR" && "text-red-500"} ${log.level === "ERROR" && "text-red-500"}
`} `}
> >
{log.level} {log.level}
</Text> </Text>
<Text className="text-xs">{log.message}</Text> <Text>{log.message}</Text>
</View> </View>
))} ))}
{logs?.length === 0 && ( {logs?.length === 0 && (

View File

@@ -1,19 +1,17 @@
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { DarkTheme, ThemeProvider } from "@react-navigation/native"; import { DarkTheme, ThemeProvider } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useFonts } from "expo-font"; import { useFonts } from "expo-font";
import { Stack } from "expo-router"; import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import { Provider as JotaiProvider } from "jotai"; import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import "react-native-reanimated"; import "react-native-reanimated";
import * as ScreenOrientation from "expo-screen-orientation";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider as JotaiProvider } from "jotai";
import { JellyfinProvider } from "@/providers/JellyfinProvider";
import { TouchableOpacity } from "react-native";
import Feather from "@expo/vector-icons/Feather";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
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. // Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
@@ -23,8 +21,6 @@ export const unstable_settings = {
}; };
export default function RootLayout() { export default function RootLayout() {
useKeepAwake();
const [loaded] = useFonts({ const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
}); });
@@ -49,30 +45,6 @@ export default function RootLayout() {
} }
}, [loaded]); }, [loaded]);
const [orientation, setOrientation] = useState(
ScreenOrientation.Orientation.PORTRAIT_UP,
);
useEffect(() => {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
ScreenOrientation.getOrientationAsync().then((info) => {
setOrientation(info);
});
// subscribe to future changes
const subscription = ScreenOrientation.addOrientationChangeListener(
(evt) => {
setOrientation(evt.orientationInfo.orientation);
},
);
// return a clean up function to unsubscribe from notifications
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
if (!loaded) { if (!loaded) {
return null; return null;
} }
@@ -80,71 +52,76 @@ export default function RootLayout() {
return ( return (
<QueryClientProvider client={queryClientRef.current}> <QueryClientProvider client={queryClientRef.current}>
<JotaiProvider> <JotaiProvider>
<JobQueueProvider> <JellyfinProvider>
<ActionSheetProvider> <StatusBar style="auto" />
<JellyfinProvider> <ThemeProvider value={DarkTheme}>
<StatusBar style="light" backgroundColor="#000" /> <Stack>
<ThemeProvider value={DarkTheme}> <Stack.Screen
<Stack> name="(auth)/(tabs)"
<Stack.Screen options={{
name="(auth)/(tabs)" headerShown: false,
options={{ title: "Home",
headerShown: false, }}
title: "Home", />
}} <Stack.Screen
/> name="(auth)/settings"
<Stack.Screen options={{
name="(auth)/settings" headerShown: true,
options={{ title: "Settings",
headerShown: true, presentation: "modal",
title: "Settings", headerLeft: () => (
headerStyle: { backgroundColor: "black" }, <TouchableOpacity onPress={() => router.back()}>
headerShadowVisible: false, <Feather name="x-circle" size={24} color="white" />
}} </TouchableOpacity>
/> ),
<Stack.Screen }}
name="(auth)/downloads" />
options={{ <Stack.Screen
headerShown: true, name="(auth)/downloads"
title: "Downloads", options={{
headerStyle: { backgroundColor: "black" }, headerShown: true,
headerShadowVisible: false, title: "Downloads",
}} }}
/> />
<Stack.Screen <Stack.Screen
name="(auth)/items/[id]/page" name="(auth)/player/offline/page"
options={{ options={{
title: "", title: "",
headerShown: false, headerShown: true,
}} headerStyle: { backgroundColor: "transparent" },
/> }}
<Stack.Screen />
name="(auth)/collections/[collection]/page" <Stack.Screen
options={{ name="(auth)/items/[id]/page"
title: "", options={{
headerShown: true, title: "",
headerStyle: { backgroundColor: "black" }, headerShown: false,
headerShadowVisible: false, }}
}} />
/> <Stack.Screen
<Stack.Screen name="(auth)/collections/[collection]/page"
name="(auth)/series/[id]/page" options={{
options={{ title: "",
title: "", headerShown: true,
headerShown: false, headerStyle: { backgroundColor: "transparent" },
}} headerShadowVisible: false,
/> }}
<Stack.Screen />
name="login" <Stack.Screen
options={{ headerShown: false, title: "Login" }} name="(auth)/series/[id]/page"
/> options={{
<Stack.Screen name="+not-found" /> title: "",
</Stack> headerShown: false,
<CurrentlyPlayingBar /> }}
</ThemeProvider> />
</JellyfinProvider> <Stack.Screen
</ActionSheetProvider> name="login"
</JobQueueProvider> options={{ headerShown: false, title: "Login" }}
/>
<Stack.Screen name="+not-found" />
</Stack>
</ThemeProvider>
</JellyfinProvider>
</JotaiProvider> </JotaiProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@@ -1,148 +0,0 @@
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 { AxiosError } from "axios";
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 [error, setError] = 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) {
const e = error as AxiosError;
setError(e.message);
} finally {
setLoading(false);
}
};
const handleConnect = (url: string) => {
setServer({ address: url.trim() });
};
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">Streamyfin</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>
<Text className="text-red-600 mb-2">{error}</Text>
<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">Streamyfin</Text>
<Text className="opacity-50">Enter a server adress</Text>
<Input
className="mb-2"
placeholder="http(s)://..."
onChangeText={setServerURL}
value={serverURL}
keyboardType="url"
returnKeyType="done"
autoCapitalize="none"
textContentType="URL"
maxLength={500}
/>
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
</View>
</View>
</KeyboardAvoidingView>
);
};
export default Login;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,80 +0,0 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const AudioTrackSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const audioStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
[item],
);
const selectedAudioSteam = useMemo(
() => audioStreams?.find((x) => x.Index === selected),
[audioStreams, selected],
);
useEffect(() => {
const index = item.MediaSources?.[0].DefaultAudioStreamIndex;
if (index !== undefined && index !== null) onChange(index);
}, []);
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedAudioSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
{audioStreams?.map((audio, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (audio.Index !== null && audio.Index !== undefined)
onChange(audio.Index);
}}
>
<DropdownMenu.ItemTitle>
{audio.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

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

@@ -1,87 +0,0 @@
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: "8 Mb/s",
value: 8000000,
},
{
key: "4 Mb/s",
value: 4000000,
},
{
key: "2 Mb/s",
value: 2000000,
},
{
key: "500 Kb/s",
value: 500000,
},
{
key: "250 Kb/s",
value: 250000,
},
];
interface Props extends React.ComponentProps<typeof View> {
onChange: (value: Bitrate) => void;
selected: Bitrate;
}
export const BitrateSelector: React.FC<Props> = ({
onChange,
selected,
...props
}) => {
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 h-12 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>
);
};

View File

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

View File

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

View File

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

View File

@@ -1,330 +0,0 @@
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 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 { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
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";
import { writeToLog } from "@/utils/log";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
export const currentlyPlayingItemAtom = atom<{
item: BaseItemDto;
playbackUrl: string;
} | null>(null);
export const CurrentlyPlayingBar: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
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],
);
const backdropUrl = useMemo(
() =>
getBackdropUrl({
api,
item,
quality: 70,
width: 200,
}),
[item],
);
useEffect(() => {
if (cp?.playbackUrl) {
play();
}
}, [cp?.playbackUrl]);
if (!cp || !api) return null;
return (
<Animated.View
style={[animatedOuterStyle]}
className="absolute left-0 w-screen"
>
<BlurView
intensity={Platform.OS === "android" ? 60 : 100}
experimentalBlurMethod={Platform.OS === "android" ? "none" : undefined}
className={`h-full w-full rounded-xl overflow-hidden ${Platform.OS === "android" && "bg-black"}`}
>
<Animated.View
style={[
{ padding: 8, borderTopLeftRadius: 12, borderTopEndRadius: 12 },
animatedInnerStyle,
]}
className="h-full w-full flex flex-row items-center justify-between overflow-hidden"
>
<View className="flex flex-row items-center space-x-4 shrink">
<TouchableOpacity
onPress={() => {
videoRef.current?.presentFullscreenPlayer();
}}
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
`}
>
{cp.playbackUrl && (
<Video
ref={videoRef}
allowsExternalPlayback
style={{ width: "100%", height: "100%" }}
playWhenInactive={true}
playInBackground={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
controls={false}
pictureInPicture={true}
poster={
backdropUrl && item?.Type === "Audio"
? backdropUrl
: undefined
}
debug={{
enable: true,
thread: true,
}}
paused={paused}
onProgress={(e) => onProgress(e)}
subtitleStyle={{
fontSize: 16,
}}
source={{
uri: cp.playbackUrl,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
}}
onBuffer={(e) =>
e.isBuffering ? console.log("Buffering...") : null
}
onPlaybackStateChanged={(e) => {
if (e.isPlaying) {
setPaused(false);
} else if (e.isSeeking) {
return;
} else {
pause();
}
}}
progressUpdateInterval={1000}
onError={(e) => {
console.log(e);
writeToLog(
"ERROR",
"Video playback error: " + JSON.stringify(e),
);
}}
renderLoader={
item?.Type !== "Audio" && (
<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>
);
};

View File

@@ -1,137 +1,141 @@
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { runningProcesses } from "@/utils/atoms/downloads"; 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 Ionicons from "@expo/vector-icons/Ionicons";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router"; import { router } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { ActivityIndicator, TouchableOpacity, View } from "react-native";
import ProgressCircle from "./ProgressCircle"; import ProgressCircle from "./ProgressCircle";
import { Text } from "./common/Text";
import { useDownloadMedia } from "@/hooks/useDownloadMedia";
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
type DownloadProps = { type DownloadProps = {
item: BaseItemDto; item: BaseItemDto;
playbackUrl: string; playbackURL: string;
}; };
export const DownloadItem: React.FC<DownloadProps> = ({ export const DownloadItem: React.FC<DownloadProps> = ({
item, item,
playbackUrl, playbackURL,
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [process] = useAtom(runningProcesses); const [process] = useAtom(runningProcesses);
const [queue, setQueue] = useAtom(queueAtom);
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item); const { downloadMedia, isDownloading, error, cancelDownload } =
useDownloadMedia(api, user?.Id);
const { data: playbackInfo, isLoading } = useQuery({ const { data: playbackInfo, isLoading } = useQuery({
queryKey: ["playbackInfo", item.Id], queryKey: ["playbackInfo", item.Id],
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id), queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
}); });
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({ const downloadFile = useCallback(async () => {
queryKey: ["downloaded", item.Id], if (!playbackInfo) return;
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( const data: BaseItemDto[] = JSON.parse(
(await AsyncStorage.getItem("downloaded_files")) || "[]", (await AsyncStorage.getItem("downloaded_files")) || "[]",
); );
return data.some((d) => d.Id === item.Id); if (data.find((d) => d.Id === item.Id)) setDownloaded(true);
}, })();
enabled: !!item.Id, }, [process]);
});
if (isLoading || isLoadingDownloaded) { if (isLoading) {
return ( return <ActivityIndicator size={"small"} color={"white"} />;
<View className="rounded h-10 aspect-square flex items-center justify-center">
<ActivityIndicator size={"small"} color={"white"} />
</View>
);
} }
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) { if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
return ( return (
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50"> <View style={{ opacity: 0.5 }}>
<Ionicons name="cloud-download-outline" size={24} color="white" /> <Ionicons name="cloud-download-outline" size={24} color="white" />
</View> </View>
); );
} }
if (process && process?.item.Id === item.Id) { if (process && process.item.Id !== item.Id!) {
return ( return (
<TouchableOpacity <TouchableOpacity onPress={() => {}} style={{ opacity: 0.5 }}>
onPress={() => { <Ionicons name="cloud-download-outline" size={24} color="white" />
router.push("/downloads"); </TouchableOpacity>
}} );
> }
<View className="rounded h-10 aspect-square flex items-center justify-center">
return (
<View>
{process ? (
<TouchableOpacity
onPress={() => {
// cancelRemuxing();
}}
className="flex flex-row items-center"
>
{process.progress === 0 ? ( {process.progress === 0 ? (
<ActivityIndicator size={"small"} color={"white"} /> <ActivityIndicator size={"small"} color={"white"} />
) : ( ) : (
<View className="-rotate-45"> <View className="relative">
<ProgressCircle <View className="-rotate-45">
size={24} <ProgressCircle
fill={process.progress} size={28}
width={4} fill={process.progress}
tintColor="#9334E9" width={4}
backgroundColor="#bdc3c7" 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> </View>
)} )}
</View>
</TouchableOpacity>
);
}
if (queue.some((i) => i.id === item.Id)) { {process?.speed && process.speed > 0 ? (
return ( <View className="ml-2">
<TouchableOpacity <Text>{process.speed.toFixed(2)}x</Text>
onPress={() => { </View>
router.push("/downloads"); ) : null}
}} </TouchableOpacity>
> ) : downloaded ? (
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50"> <TouchableOpacity
<Ionicons name="hourglass" size={24} color="white" /> onPress={() => {
</View> router.push(
</TouchableOpacity> `/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`,
); );
} }}
>
if (downloaded) { <Ionicons name="cloud-download" size={26} color="#16a34a" />
return ( </TouchableOpacity>
<TouchableOpacity ) : (
onPress={() => { <TouchableOpacity
router.push("/downloads"); onPress={() => {
}} // downloadFile();
> // startRemuxing();
<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" /> <Ionicons name="cloud-download-outline" size={26} color="white" />
</View> </TouchableOpacity>
</TouchableOpacity> )}
); </View>
} );
}; };

View File

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

View File

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

@@ -29,9 +29,15 @@ export const OfflineVideoPlayer: React.FC<VideoPlayerProps> = ({ url }) => {
uri: url, uri: url,
isNetwork: false, isNetwork: false,
}} }}
controls
ref={videoRef} ref={videoRef}
onError={onError} onError={onError}
ignoreSilentSwitch="ignore" resizeMode="contain"
reportBandwidth
style={{
width: "100%",
aspectRatio: 16 / 9,
}}
/> />
); );
}; };

View File

@@ -1,38 +0,0 @@
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,7 +9,6 @@ import Animated, {
useScrollViewOffset, useScrollViewOffset,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Chromecast } from "./Chromecast";
const HEADER_HEIGHT = 400; const HEADER_HEIGHT = 400;
@@ -33,14 +32,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
translateY: interpolate( translateY: interpolate(
scrollOffset.value, scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT], [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75], [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
), ),
}, },
{ {
scale: interpolate( scale: interpolate(
scrollOffset.value, scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT], [-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[2, 1, 1], [2, 1, 1]
), ),
}, },
], ],
@@ -62,7 +61,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
onPress={() => router.back()} onPress={() => router.back()}
className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900" className="absolute left-4 z-50 bg-black rounded-full p-2 border border-neutral-900"
style={{ style={{
top: inset.top + 17, top: inset.top,
}} }}
> >
<Ionicons <Ionicons
@@ -73,15 +72,6 @@ export const ParallaxScrollView: React.FC<Props> = ({
/> />
</TouchableOpacity> </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 && ( {logo && (
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center"> <View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
{logo} {logo}
@@ -99,9 +89,7 @@ export const ParallaxScrollView: React.FC<Props> = ({
> >
{headerImage} {headerImage}
</Animated.View> </Animated.View>
<View className="flex-1 overflow-hidden bg-black pb-24"> <View className="flex-1 overflow-hidden bg-black">{children}</View>
{children}
</View>
</Animated.ScrollView> </Animated.ScrollView>
</View> </View>
); );

View File

@@ -1,65 +0,0 @@
import { Button } from "./Button";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
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";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
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}
iconRight={
<View className="flex flex-row items-center space-x-2">
<Ionicons name="play-circle" size={24} color="white" />
{chromecastReady && <Feather name="cast" size={22} color="white" />}
</View>
}
{...props}
>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Button>
);
};

View File

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

View File

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

View File

@@ -1,92 +0,0 @@
import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { Text } from "./common/Text";
import { atom, useAtom } from "jotai";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useEffect, useMemo } from "react";
import { MediaStream } from "@jellyfin/sdk/lib/generated-client/models";
import { tc } from "@/utils/textTools";
interface Props extends React.ComponentProps<typeof View> {
item: BaseItemDto;
onChange: (value: number) => void;
selected: number;
}
export const SubtitleTrackSelector: React.FC<Props> = ({
item,
onChange,
selected,
...props
}) => {
const subtitleStreams = useMemo(
() =>
item.MediaSources?.[0].MediaStreams?.filter(
(x) => x.Type === "Subtitle",
) ?? [],
[item],
);
const selectedSubtitleSteam = useMemo(
() => subtitleStreams.find((x) => x.Index === selected),
[subtitleStreams, selected],
);
useEffect(() => {
const index = item.MediaSources?.[0].DefaultSubtitleStreamIndex;
if (index !== undefined && index !== null) {
onChange(index);
} else {
// Get first subtitle stream
const firstSubtitle = subtitleStreams.find((x) => x.Index !== undefined);
if (firstSubtitle?.Index !== undefined) {
onChange(firstSubtitle.Index);
}
}
}, []);
if (subtitleStreams.length === 0) return null;
return (
<View className="flex flex-row items-center justify-between" {...props}>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className="flex flex-col mb-2">
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
<View className="flex flex-row">
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text className="">
{tc(selectedSubtitleSteam?.DisplayTitle, 13)}
</Text>
</TouchableOpacity>
</View>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={true}
side="bottom"
align="start"
alignOffset={0}
avoidCollisions={true}
collisionPadding={8}
sideOffset={8}
>
<DropdownMenu.Label>Subtitles</DropdownMenu.Label>
{subtitleStreams?.map((subtitle, idx: number) => (
<DropdownMenu.Item
key={idx.toString()}
onSelect={() => {
if (subtitle.Index !== undefined && subtitle.Index !== null)
onChange(subtitle.Index);
}}
>
<DropdownMenu.ItemTitle>
{subtitle.DisplayTitle}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
};

View File

@@ -23,14 +23,32 @@ import { chromecastProfile } from "@/utils/profiles/chromecast";
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped"; import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
type VideoPlayerProps = { type VideoPlayerProps = {
itemId: string; itemId: string;
onChangePlaybackURL: (url: string | null) => void; onChangePlaybackURL: (url: string | null) => void;
}; };
export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({ 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, itemId,
onChangePlaybackURL, onChangePlaybackURL,
}) => { }) => {
@@ -176,8 +194,6 @@ export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
}); });
}, [item, client, playbackURL]); }, [item, client, playbackURL]);
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
useEffect(() => { useEffect(() => {
videoRef.current?.pause(); videoRef.current?.pause();
}, []); }, []);
@@ -247,15 +263,14 @@ export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
<Button <Button
disabled={!enableVideo} disabled={!enableVideo}
onPress={() => { onPress={() => {
// if (chromecastReady) { if (chromecastReady) {
// cast(); cast();
// } else { } else {
// setTimeout(() => { setTimeout(() => {
// if (!videoRef.current) return; if (!videoRef.current) return;
// videoRef.current.presentFullscreenPlayer(); videoRef.current.presentFullscreenPlayer();
// }, 1000); }, 1000);
// } }
if (item) setCp(item);
}} }}
iconRight={ iconRight={
chromecastReady ? ( chromecastReady ? (

View File

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

@@ -1,22 +1,15 @@
import React, { useEffect } from "react"; import React from "react";
import { TextInputProps, TextProps } from "react-native"; import { TextInputProps, TextProps } from "react-native";
import { TextInput } from "react-native"; import { TextInput } from "react-native";
export function Input(props: TextInputProps) { export function Input(props: TextInputProps) {
const { style, ...otherProps } = props; const { style, ...otherProps } = props;
const inputRef = React.useRef<TextInput>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return ( return (
<TextInput <TextInput
ref={inputRef}
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900" className="p-4 border border-neutral-800 rounded-xl bg-neutral-900"
allowFontScaling={false} allowFontScaling={false}
style={[{ color: "white" }, style]} style={[{ color: "white" }, style]}
{...otherProps} {...otherProps}
placeholderTextColor={"#9CA3AF"}
/> />
); );
} }

View File

@@ -1,39 +1,25 @@
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router } from "expo-router";
import { TouchableOpacity } from "react-native"; import { TouchableOpacity } from "react-native";
import * as ContextMenu from "zeego/context-menu"; import * as ContextMenu from "zeego/context-menu";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import { useFiles } from "@/hooks/useFiles"; import { useFiles } from "@/hooks/useFiles";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useCallback } from "react"; import { useRef, useMemo, useState } from "react";
import Video, { VideoRef } from "react-native-video";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useAtom } from "jotai";
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => { export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles(); const { deleteFile } = useFiles();
const [_, setCp] = useAtom(currentlyPlayingItemAtom); const videoRef = useRef<VideoRef | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// const fetchFileSize = async () => { const openFile = () => {
// try { videoRef.current?.presentFullscreenPlayer();
// 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({ const fileUrl = useMemo(() => {
// queryKey: ["fileSize", item?.Id], return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
// queryFn: fetchFileSize,
// });
const openFile = useCallback(() => {
setCp({
item,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item]); }, [item]);
const options = [ const options = [
@@ -59,12 +45,6 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
<Text className=" text-xs opacity-50"> <Text className=" text-xs opacity-50">
Episode {item.IndexNumber} Episode {item.IndexNumber}
</Text> </Text>
{/* <Text className=" text-xs opacity-50">
Size:{" "}
{fileSize
? `${(fileSize / 1000000).toFixed(0)} MB`
: "Calculating..."}{" "}
</Text> */}
</TouchableOpacity> </TouchableOpacity>
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Content <ContextMenu.Content
@@ -92,6 +72,26 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
))} ))}
</ContextMenu.Content> </ContextMenu.Content>
</ContextMenu.Root> </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}
/>
</> </>
); );
}; };

View File

@@ -3,34 +3,30 @@ import { Text } from "../common/Text";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { runtimeTicksToMinutes } from "@/utils/time"; import { runtimeTicksToMinutes } from "@/utils/time";
import * as ContextMenu from "zeego/context-menu"; import * as ContextMenu from "zeego/context-menu";
import { router } from "expo-router";
import { useFiles } from "@/hooks/useFiles"; import { useFiles } from "@/hooks/useFiles";
import Video, {
OnBufferData,
OnPlaybackStateChangedData,
OnProgressData,
OnVideoErrorData,
VideoRef,
} from "react-native-video";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useCallback } from "react"; import { useMemo, useRef, useState } from "react";
import * as Haptics from "expo-haptics"; 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 }) => { export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
const { deleteFile } = useFiles(); const { deleteFile } = useFiles();
const [_, setCp] = useAtom(currentlyPlayingItemAtom); const videoRef = useRef<VideoRef | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// const fetchFileSize = async () => { const openFile = () => {
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`; videoRef.current?.presentFullscreenPlayer();
// const info = await FileSystem.getInfoAsync(filePath); };
// return info.exists ? info.size : null;
// };
// const { data: fileSize } = useQuery({ const fileUrl = useMemo(() => {
// queryKey: ["fileSize", item?.Id], return `${FileSystem.documentDirectory}/${item.Id}.mp4`;
// queryFn: fetchFileSize,
// });
const openFile = useCallback(() => {
setCp({
item,
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
});
}, [item]); }, [item]);
const options = [ const options = [
@@ -53,17 +49,11 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4" className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
> >
<Text className=" font-bold">{item.Name}</Text> <Text className=" font-bold">{item.Name}</Text>
<View className="flex flex-col"> <View className="flex flex-row items-center justify-between">
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text> <Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
<Text className=" text-xs opacity-50"> <Text className=" text-xs opacity-50">
{runtimeTicksToMinutes(item.RunTimeTicks)} {runtimeTicksToMinutes(item.RunTimeTicks)}
</Text> </Text>
{/* <Text className=" text-xs opacity-50">
Size:{" "}
{fileSize
? `${(fileSize / 1000000).toFixed(0)} MB`
: "Calculating..."}{" "}
</Text>*/}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
</ContextMenu.Trigger> </ContextMenu.Trigger>
@@ -92,6 +82,26 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
))} ))}
</ContextMenu.Content> </ContextMenu.Content>
</ContextMenu.Root> </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}
/>
</> </>
); );
}; };

View File

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

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

@@ -1,28 +1,26 @@
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router"; import { router } from "expo-router";
import { atom, useAtom } from "jotai"; import { useAtom } from "jotai";
import { useMemo } from "react"; import { useEffect, useState } from "react";
import { TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu"; import * as DropdownMenu from "zeego/dropdown-menu";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
import { HorizontalScroll } from "../common/HorrizontalScroll"; import { HorizontalScroll } from "../common/HorrizontalScroll";
import { Text } from "../common/Text"; import { Text } from "../common/Text";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { ItemCardText } from "../ItemCardText";
type Props = { type Props = {
item: BaseItemDto; item: BaseItemDto;
}; };
export const seasonIndexAtom = atom<number>(1);
export const SeasonPicker: React.FC<Props> = ({ item }) => { export const SeasonPicker: React.FC<Props> = ({ item }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [seasonIndex, setSeasonIndex] = useAtom(seasonIndexAtom);
const router = useRouter(); const [selectedSeason, setSelectedSeason] = useState<number | null>(null);
const [selectedSeasonId, setSelectedSeasonId] = useState<string | null>(null);
const { data: seasons } = useQuery({ const { data: seasons } = useQuery({
queryKey: ["seasons", item.Id], queryKey: ["seasons", item.Id],
@@ -40,7 +38,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: { headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
}, },
}, }
); );
return response.data.Items; return response.data.Items;
@@ -48,12 +46,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id, 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({ const { data: episodes } = useQuery({
queryKey: ["episodes", item.Id, selectedSeasonId], queryKey: ["episodes", item.Id, selectedSeasonId],
queryFn: async () => { queryFn: async () => {
@@ -70,7 +62,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
headers: { headers: {
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`, Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
}, },
}, }
); );
return response.data.Items as BaseItemDto[]; return response.data.Items as BaseItemDto[];
@@ -78,13 +70,22 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
enabled: !!api && !!user?.Id && !!item.Id && !!selectedSeasonId, 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 ( return (
<View className="mb-2"> <View className="mb-2">
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className="flex flex-row px-4"> <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"> <TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
<Text>Season {seasonIndex}</Text> <Text>Season {selectedSeason}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -102,7 +103,8 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
<DropdownMenu.Item <DropdownMenu.Item
key={season.Name} key={season.Name}
onSelect={() => { onSelect={() => {
setSeasonIndex(season.IndexNumber); setSelectedSeason(season.IndexNumber);
setSelectedSeasonId(season.Id);
}} }}
> >
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>

View File

@@ -1,37 +0,0 @@
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, tint: tintColorDark,
icon: "#9BA1A6", icon: "#9BA1A6",
tabIconDefault: "#9BA1A6", tabIconDefault: "#9BA1A6",
tabIconSelected: "#9333ea", tabIconSelected: "#EE4B2B",
}; };

View File

@@ -20,19 +20,7 @@
"simulator": true "simulator": true
} }
}, },
"production": { "production": {}
"channel": "0.4.2",
"android": {
"image": "latest"
}
},
"production-apk": {
"channel": "0.4.2",
"android": {
"buildType": "apk",
"image": "latest"
}
}
}, },
"submit": { "submit": {
"production": {} "production": {}

View File

@@ -1,138 +0,0 @@
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 -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(
"INFO",
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
);
try {
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
const videoLength =
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
const totalFrames = videoLength * fps;
const processedFrames = statistics.getVideoFrameNumber();
const speed = statistics.getSpeed();
const percentage =
totalFrames > 0
? Math.floor((processedFrames / totalFrames) * 100)
: 0;
setProgress((prev) =>
prev?.item.Id === item.Id!
? { ...prev, progress: percentage, speed }
: prev,
);
});
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
await new Promise<void>((resolve, reject) => {
FFmpegKit.executeAsync(command, async (session) => {
try {
const returnCode = await session.getReturnCode();
if (returnCode.isValueSuccess()) {
await updateDownloadedFiles(item);
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
);
resolve();
} else if (returnCode.isValueError()) {
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
reject(new Error("Remuxing failed")); // Reject the promise on error
} else if (returnCode.isValueCancel()) {
writeToLog(
"INFO",
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
);
resolve();
}
setProgress(null);
} catch (error) {
reject(error);
}
});
});
} catch (error) {
console.error("Failed to remux:", error);
writeToLog(
"ERROR",
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
);
setProgress(null);
throw error; // Re-throw the error to propagate it to the caller
}
}, [output, item, 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}`,
);
}
}

View File

@@ -15,8 +15,6 @@
"preset": "jest-expo" "preset": "jest-expo"
}, },
"dependencies": { "dependencies": {
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
"@expo/react-native-action-sheet": "^4.1.0",
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@jellyfin/sdk": "^0.10.0", "@jellyfin/sdk": "^0.10.0",
"@kesha-antonov/react-native-background-downloader": "^3.2.0", "@kesha-antonov/react-native-background-downloader": "^3.2.0",
@@ -26,9 +24,7 @@
"@react-navigation/native": "^6.0.2", "@react-navigation/native": "^6.0.2",
"@tanstack/react-query": "^5.51.16", "@tanstack/react-query": "^5.51.16",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"axios": "^1.7.3",
"expo": "~51.0.26", "expo": "~51.0.26",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5", "expo-build-properties": "~0.12.5",
"expo-constants": "~16.0.2", "expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.22", "expo-dev-client": "~4.0.22",
@@ -38,16 +34,11 @@
"expo-image": "~1.12.13", "expo-image": "~1.12.13",
"expo-keep-awake": "~13.0.2", "expo-keep-awake": "~13.0.2",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
"expo-navigation-bar": "~3.0.7",
"expo-router": "~3.5.21", "expo-router": "~3.5.21",
"expo-screen-orientation": "~7.0.5",
"expo-sensors": "~13.0.9",
"expo-splash-screen": "~0.27.5", "expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1", "expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7", "expo-system-ui": "~3.0.7",
"expo-updates": "~0.25.22",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"ffmpeg-kit-react-native": "^6.0.2",
"jotai": "^2.9.1", "jotai": "^2.9.1",
"nativewind": "^2.0.11", "nativewind": "^2.0.11",
"react": "18.2.0", "react": "18.2.0",
@@ -57,7 +48,6 @@
"react-native-compressor": "^1.8.25", "react-native-compressor": "^1.8.25",
"react-native-gesture-handler": "~2.16.1", "react-native-gesture-handler": "~2.16.1",
"react-native-get-random-values": "^1.11.0", "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-context-menu": "^2.5.1",
"react-native-ios-utilities": "^4.4.5", "react-native-ios-utilities": "^4.4.5",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",

View File

@@ -12,7 +12,6 @@ import React, {
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { Platform } from "react-native";
import uuid from "react-native-uuid"; import uuid from "react-native-uuid";
interface Server { interface Server {
@@ -31,7 +30,7 @@ interface JellyfinContextValue {
} }
const JellyfinContext = createContext<JellyfinContextValue | undefined>( const JellyfinContext = createContext<JellyfinContextValue | undefined>(
undefined, undefined
); );
const getOrSetDeviceId = async () => { const getOrSetDeviceId = async () => {
@@ -56,9 +55,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setJellyfin( setJellyfin(
() => () =>
new Jellyfin({ new Jellyfin({
clientInfo: { name: "Streamyfin", version: "0.4.2" }, clientInfo: { name: "Streamyfin", version: "1.0.0" },
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id }, deviceInfo: { name: "iOS", id },
}), })
); );
})(); })();
}, []); }, []);
@@ -67,8 +66,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const [user, setUser] = useAtom(userAtom); const [user, setUser] = useAtom(userAtom);
const discoverServers = async (url: string): Promise<Server[]> => { const discoverServers = async (url: string): Promise<Server[]> => {
const servers = const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
await jellyfin?.discovery.getRecommendedServerCandidates(url); url
);
return servers?.map((server) => ({ address: server.address })) || []; return servers?.map((server) => ({ address: server.address })) || [];
}; };
@@ -144,7 +144,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const token = await AsyncStorage.getItem("token"); const token = await AsyncStorage.getItem("token");
const serverUrl = await AsyncStorage.getItem("serverUrl"); const serverUrl = await AsyncStorage.getItem("serverUrl");
const user = JSON.parse( const user = JSON.parse(
(await AsyncStorage.getItem("user")) as string, (await AsyncStorage.getItem("user")) as string
) as UserDto; ) as UserDto;
if (serverUrl && token && user.Id && jellyfin) { if (serverUrl && token && user.Id && jellyfin) {

View File

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

View File

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

View File

@@ -14,8 +14,6 @@ export const getStreamUrl = async ({
maxStreamingBitrate, maxStreamingBitrate,
sessionData, sessionData,
deviceProfile = ios12, deviceProfile = ios12,
audioStreamIndex = 0,
subtitleStreamIndex = 0,
}: { }: {
api: Api | null | undefined; api: Api | null | undefined;
item: BaseItemDto | null | undefined; item: BaseItemDto | null | undefined;
@@ -24,8 +22,6 @@ export const getStreamUrl = async ({
maxStreamingBitrate?: number; maxStreamingBitrate?: number;
sessionData: PlaybackInfoResponse; sessionData: PlaybackInfoResponse;
deviceProfile: any; deviceProfile: any;
audioStreamIndex?: number;
subtitleStreamIndex?: number;
}) => { }) => {
if (!api || !userId || !item?.Id) { if (!api || !userId || !item?.Id) {
return null; return null;
@@ -44,8 +40,6 @@ export const getStreamUrl = async ({
AutoOpenLiveStream: true, AutoOpenLiveStream: true,
MediaSourceId: itemId, MediaSourceId: itemId,
AllowVideoStreamCopy: maxStreamingBitrate ? false : true, AllowVideoStreamCopy: maxStreamingBitrate ? false : true,
AudioStreamIndex: audioStreamIndex,
SubtitleStreamIndex: subtitleStreamIndex,
}, },
{ {
headers: { headers: {
@@ -64,28 +58,8 @@ export const getStreamUrl = async ({
} }
if (mediaSource.SupportsDirectPlay) { if (mediaSource.SupportsDirectPlay) {
if (item.MediaType === "Video") { console.log("Using direct stream!");
console.log("Using direct stream for video!"); return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
return `${api.basePath}/Videos/${itemId}/stream.mp4?playSessionId=${sessionData.PlaySessionId}&mediaSourceId=${itemId}&static=true`;
} else if (item.MediaType === "Audio") {
console.log("Using direct stream for audio!");
const searchParams = new URLSearchParams({
UserId: userId,
DeviceId: api.deviceInfo.id,
MaxStreamingBitrate: "140000000",
Container:
"opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg",
TranscodingContainer: "mp4",
TranscodingProtocol: "hls",
AudioCodec: "aac",
api_key: api.accessToken,
PlaySessionId: sessionData.PlaySessionId,
StartTimeTicks: "0",
EnableRedirection: "true",
EnableRemoteMedia: "false",
});
return `${api.basePath}/Audio/${itemId}/universal?${searchParams.toString()}`;
}
} }
console.log("Using transcoded stream!"); console.log("Using transcoded stream!");

View File

@@ -1,7 +0,0 @@
/*
* Truncate a text longer than a certain length
*/
export const tc = (text: string | null | undefined, length: number = 20) => {
if (!text) return "";
return text.length > length ? text.substr(0, length) + "..." : text;
};