mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-05 21:48:31 +01:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8bc954c1 | ||
|
|
f87824ec58 | ||
|
|
78556e8764 | ||
|
|
3c678add0f | ||
|
|
0c98980b1d | ||
|
|
66179a68ea | ||
|
|
fdd07dce3b | ||
|
|
0dc32d58cf | ||
|
|
e56c3e5c97 | ||
|
|
bd8bf8349f | ||
|
|
ede390e74b | ||
|
|
0eca453c9a | ||
|
|
65838034b6 | ||
|
|
e715b3daa4 | ||
|
|
37b7fc1c20 | ||
|
|
9ee30ff1ce | ||
|
|
026a286ebf | ||
|
|
e522e1dcc0 | ||
|
|
a80e065cdb | ||
|
|
f4f2d37aea | ||
|
|
e65ed3db0e | ||
|
|
cb9dfe2c83 | ||
|
|
bc4b07c76b | ||
|
|
150eb1809f | ||
|
|
8afe7dc5e4 | ||
|
|
855e00a676 | ||
|
|
5289c0519f | ||
|
|
4b1eb2218f | ||
|
|
a99e7b950e | ||
|
|
51fc2a0edb | ||
|
|
3a13503d1d | ||
|
|
2fdf90ab4b | ||
|
|
6fed0c1c77 | ||
|
|
ee7ff3444e | ||
|
|
dec175a300 | ||
|
|
27099d3184 | ||
|
|
bfad77dd7a | ||
|
|
74a33f8f82 | ||
|
|
75de878618 | ||
|
|
9628285701 | ||
|
|
b206be6bcf | ||
|
|
656d4ba46b | ||
|
|
b1025c81ae | ||
|
|
b05b43c12e | ||
|
|
11f9d0fe33 | ||
|
|
0498f2e718 | ||
|
|
077f99fd46 | ||
|
|
cc72186a80 | ||
|
|
65837cd303 | ||
|
|
d5ee79d740 | ||
|
|
040ef3b79a | ||
|
|
07c0f81f36 | ||
|
|
a62e5d24da |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -27,6 +27,5 @@ Streamyfin.app
|
|||||||
|
|
||||||
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
pc-api-7079014811501811218-719-3b9f15aeccf8.json
|
||||||
credentials.json
|
credentials.json
|
||||||
development.apk
|
*.apk
|
||||||
Streamyfin.apk
|
*.ipa
|
||||||
Streamyfin.ipa
|
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -12,10 +12,11 @@ Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Exp
|
|||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🔗 Connect to your Jellyfin instance: Easily link your Jellyfin server and access your media library.
|
- 📱 **Native video player**: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
||||||
- 📱 Native video player: Playback with the platform native video player. With support for subtitles, playback speed control, and more.
|
- 📺 **Picture in Picture** (iPhone only): Watch movies in PiP mode on your iPhone.
|
||||||
- 📥 Download media (Experimental): Save your media locally and watch it offline.
|
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
||||||
- 📡 Chromecast media (Experimental): Cast your media to any Chromecast-enabled device.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
|
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
@@ -25,15 +26,21 @@ Streamyfin includes some exciting experimental features like media downloading a
|
|||||||
|
|
||||||
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||||
|
|
||||||
## 🛠️ Beta testing (iOS/Android)
|
## Get it now
|
||||||
|
|
||||||
## TestFlight
|
<a href="https://apps.apple.com/se/app/streamyfin/id6593660679?l=en-GB">
|
||||||
|
<img height=75 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
### TestFlight
|
||||||
|
|
||||||
|
Get the latest updates by using the TestFlight version of the app.
|
||||||
|
|
||||||
<a href="https://testflight.apple.com/join/CWBaAAK2">
|
<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
|
### Play Store Open Beta
|
||||||
|
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin">
|
<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"/>
|
<img height=75 alt="Get the beta on Google Play" src="./assets/en_badge_web_generic.png"/>
|
||||||
|
|||||||
25
app.json
25
app.json
@@ -2,8 +2,8 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.3.2",
|
"version": "0.4.2",
|
||||||
"orientation": "portrait",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
@@ -18,20 +18,24 @@
|
|||||||
"requireFullScreen": true,
|
"requireFullScreen": true,
|
||||||
"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": "hermes",
|
||||||
"versionCode": 10,
|
"versionCode": 15,
|
||||||
"orientation": "default",
|
|
||||||
"androidNavigationBar": {
|
|
||||||
"visible": true,
|
|
||||||
"barStyle": "dark-content",
|
|
||||||
"backgroundColor": "#000000"
|
|
||||||
},
|
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/icon.png"
|
"foregroundImage": "./assets/images/icon.png"
|
||||||
},
|
},
|
||||||
@@ -61,6 +65,7 @@
|
|||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
"enableNotificationControls": true,
|
"enableNotificationControls": true,
|
||||||
|
"enableBackgroundAudio": true,
|
||||||
"androidExtensions": {
|
"androidExtensions": {
|
||||||
"useExoplayerRtsp": false,
|
"useExoplayerRtsp": false,
|
||||||
"useExoplayerSmoothStreaming": false,
|
"useExoplayerSmoothStreaming": false,
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import React, { useEffect } from "react";
|
|||||||
import * as NavigationBar from "expo-navigation-bar";
|
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 } from "react-native";
|
import { Platform, TouchableOpacity, View } 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(() => {
|
useEffect(() => {
|
||||||
@@ -41,18 +42,23 @@ export default function TabLayout() {
|
|||||||
router.push("/(auth)/downloads");
|
router.push("/(auth)/downloads");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Feather name="download" color={"white"} size={24} />
|
<Feather name="download" color={"white"} size={22} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
),
|
),
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
|
<View className="flex flex-row items-center space-x-2">
|
||||||
|
<Chromecast />
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={{ marginHorizontal: 17 }}
|
style={{ marginRight: 17 }}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Feather name="settings" color={"white"} size={24} />
|
<View className="h-10 aspect-square flex items-center justify-center rounded">
|
||||||
|
<Feather name="settings" color={"white"} size={22} />
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ 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 { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import {
|
||||||
|
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 { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
@@ -23,16 +26,21 @@ 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 () => {
|
||||||
(api &&
|
if (!api) return null;
|
||||||
(
|
const response = await getItemsApi(api).getItems({
|
||||||
await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
})
|
ids: [collectionId],
|
||||||
).data.Items?.find((item) => item.Id == collectionId)) ||
|
});
|
||||||
null,
|
const data = response.data.Items?.[0];
|
||||||
|
return data;
|
||||||
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
enabled: !!api && !!user?.Id,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
@@ -45,41 +53,85 @@ const page: React.FC = () => {
|
|||||||
}>({
|
}>({
|
||||||
queryKey: ["collection-items", collectionId, startIndex],
|
queryKey: ["collection-items", collectionId, startIndex],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api) return [];
|
if (!api || !collectionId)
|
||||||
|
return {
|
||||||
|
Items: [],
|
||||||
|
TotalRecordCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
const response = await api.axiosInstance.get(
|
const sortBy: ItemSortBy[] = [];
|
||||||
`${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 || [];
|
switch (collection?.CollectionType) {
|
||||||
},
|
case "movies":
|
||||||
enabled: !!collection && !!api,
|
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,
|
||||||
|
});
|
||||||
|
// 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;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -91,7 +143,8 @@ 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}-{startIndex + 100} of {totalItems}
|
{startIndex + 1}-{Math.min(startIndex + 100, totalItems || 0)} of{" "}
|
||||||
|
{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
|
||||||
@@ -125,7 +178,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: any, index: number) => (
|
{data?.Items?.map((item: BaseItemDto, index: number) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "33%",
|
maxWidth: "33%",
|
||||||
@@ -134,10 +187,12 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
key={index}
|
key={index}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (collection?.CollectionType === "movies") {
|
if (item?.Type === "Series") {
|
||||||
router.push(`/items/${item.Id}/page`);
|
|
||||||
} else if (collection?.CollectionType === "tvshows") {
|
|
||||||
router.push(`/series/${item.Id}/page`);
|
router.push(`/series/${item.Id}/page`);
|
||||||
|
} else if (item.IsFolder) {
|
||||||
|
router.push(`/collections/${item?.Id}/page`);
|
||||||
|
} else {
|
||||||
|
router.push(`/items/${item.Id}/page`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,14 +16,20 @@ 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 { 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"],
|
queryKey: ["downloaded_files", process?.item.Id],
|
||||||
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(
|
||||||
@@ -41,8 +47,6 @@ 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;
|
||||||
|
|
||||||
@@ -65,7 +69,36 @@ const downloads: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<ScrollView>
|
<ScrollView>
|
||||||
<View className="px-4 py-4">
|
<View className="px-4 py-4">
|
||||||
<View className="mb-4">
|
<View className="mb-4 flex flex-col space-y-4">
|
||||||
|
<View>
|
||||||
|
<Text className="text-2xl font-bold mb-2">Queue</Text>
|
||||||
|
<View className="flex flex-col space-y-2">
|
||||||
|
{queue.map((q) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push(`/(auth)/items/${q.item.Id}/page`)}
|
||||||
|
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text className="font-semibold">{q.item.Name}</Text>
|
||||||
|
<Text className="text-xs opacity-50">{q.item.Type}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
setQueue((prev) => prev.filter((i) => i.id !== q.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color="red" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{queue.length === 0 && (
|
||||||
|
<Text className="opacity-50">No items in queue</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
<Text className="text-2xl font-bold mb-2">Active download</Text>
|
||||||
{process?.item ? (
|
{process?.item ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -76,12 +109,16 @@ const downloads: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<View>
|
<View>
|
||||||
<Text className="font-semibold">{process.item.Name}</Text>
|
<Text className="font-semibold">{process.item.Name}</Text>
|
||||||
<Text className="text-xs opacity-50">{process.item.Type}</Text>
|
<Text className="text-xs opacity-50">
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-red-600">
|
{process.item.Type}
|
||||||
|
</Text>
|
||||||
|
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
||||||
<Text className="text-xs">
|
<Text className="text-xs">
|
||||||
{process.progress.toFixed(0)}%
|
{process.progress.toFixed(0)}%
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs">{process.speed?.toFixed(2)}x</Text>
|
<Text className="text-xs">
|
||||||
|
{process.speed?.toFixed(2)}x
|
||||||
|
</Text>
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-xs">ETA {eta}</Text>
|
<Text className="text-xs">ETA {eta}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -97,7 +134,7 @@ const downloads: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View
|
<View
|
||||||
className={`
|
className={`
|
||||||
absolute bottom-0 left-0 h-1 bg-red-600
|
absolute bottom-0 left-0 h-1 bg-purple-600
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
width: process.progress
|
width: process.progress
|
||||||
@@ -110,6 +147,7 @@ const downloads: React.FC = () => {
|
|||||||
<Text className="opacity-50">No active downloads</Text>
|
<Text className="opacity-50">No active downloads</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
{movies.length > 0 && (
|
{movies.length > 0 && (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
<View className="flex flex-row items-center justify-between mb-2">
|
<View className="flex flex-row items-center justify-between mb-2">
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
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 { router, useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import { ActivityIndicator, ScrollView, View } from "react-native";
|
||||||
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";
|
||||||
@@ -34,6 +27,13 @@ import CastContext, {
|
|||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||||
import ios12 from "@/utils/profiles/ios12";
|
import ios12 from "@/utils/profiles/ios12";
|
||||||
import { currentlyPlayingItemAtom } from "@/components/CurrentlyPlayingBar";
|
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();
|
||||||
@@ -45,7 +45,9 @@ const page: React.FC = () => {
|
|||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
|
|
||||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
||||||
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
|
useState<number>(0);
|
||||||
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
const [maxBitrate, setMaxBitrate] = useState<Bitrate>({
|
||||||
key: "Max",
|
key: "Max",
|
||||||
value: undefined,
|
value: undefined,
|
||||||
@@ -95,7 +97,14 @@ const page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: playbackUrl } = useQuery({
|
const { data: playbackUrl } = useQuery({
|
||||||
queryKey: ["playbackUrl", item?.Id, maxBitrate, castDevice],
|
queryKey: [
|
||||||
|
"playbackUrl",
|
||||||
|
item?.Id,
|
||||||
|
maxBitrate,
|
||||||
|
castDevice,
|
||||||
|
selectedAudioStream,
|
||||||
|
selectedSubtitleStream,
|
||||||
|
],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id || !sessionData) return null;
|
if (!api || !user?.Id || !sessionData) return null;
|
||||||
|
|
||||||
@@ -107,21 +116,26 @@ const page: React.FC = () => {
|
|||||||
maxStreamingBitrate: maxBitrate.value,
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
|
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios12,
|
||||||
|
audioStreamIndex: selectedAudioStream,
|
||||||
|
subtitleStreamIndex: selectedSubtitleStream,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("Transcode URL: ", url);
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
},
|
},
|
||||||
enabled: !!sessionData,
|
enabled: !!sessionData,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
|
|
||||||
const onPressPlay = useCallback(async () => {
|
const onPressPlay = useCallback(
|
||||||
|
async (type: "device" | "cast" = "device") => {
|
||||||
if (!playbackUrl || !item) return;
|
if (!playbackUrl || !item) return;
|
||||||
|
|
||||||
if (chromecastReady && client) {
|
if (type === "cast" && client) {
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
await CastContext.getPlayServicesState().then((state) => {
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
if (state && state !== PlayServicesState.SUCCESS)
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
CastContext.showPlayServicesErrorDialog(state);
|
||||||
@@ -146,7 +160,9 @@ const page: React.FC = () => {
|
|||||||
playbackUrl,
|
playbackUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [playbackUrl, item]);
|
},
|
||||||
|
[playbackUrl, item],
|
||||||
|
);
|
||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
@@ -187,75 +203,57 @@ const page: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col px-4 mb-4 pt-4">
|
<View className="flex flex-col px-4 pt-4">
|
||||||
<View className="flex flex-col">
|
<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>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<View className="flex flex-row items-center self-center px-4">
|
<MoviesTitleHeader item={item} />
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
<Chromecast />
|
<PlayedStatus item={item} />
|
||||||
</View>
|
</View>
|
||||||
<Text>{item.Overview}</Text>
|
|
||||||
|
<OverviewText text={item.Overview} />
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col p-4">
|
<View className="flex flex-col p-4 w-full">
|
||||||
|
<View className="flex flex-row items-center space-x-2 w-full">
|
||||||
<BitrateSelector
|
<BitrateSelector
|
||||||
onChange={(val) => setMaxBitrate(val)}
|
onChange={(val) => setMaxBitrate(val)}
|
||||||
selected={maxBitrate}
|
selected={maxBitrate}
|
||||||
/>
|
/>
|
||||||
|
<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
|
<PlayButton
|
||||||
item={item}
|
item={item}
|
||||||
chromecastReady={chromecastReady}
|
chromecastReady={chromecastReady}
|
||||||
onPress={onPressPlay}
|
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 ">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const page: React.FC = () => {
|
|||||||
itemId: seriesId,
|
itemId: seriesId,
|
||||||
}),
|
}),
|
||||||
enabled: !!seriesId && !!api,
|
enabled: !!seriesId && !!api,
|
||||||
staleTime: 0,
|
staleTime: 60,
|
||||||
});
|
});
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
import * as ScreenOrientation from "expo-screen-orientation";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
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();
|
||||||
@@ -20,6 +23,8 @@ 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"),
|
||||||
});
|
});
|
||||||
@@ -75,10 +80,12 @@ export default function RootLayout() {
|
|||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClientRef.current}>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
|
<JobQueueProvider>
|
||||||
|
<ActionSheetProvider>
|
||||||
<JellyfinProvider>
|
<JellyfinProvider>
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<ThemeProvider value={DarkTheme}>
|
<ThemeProvider value={DarkTheme}>
|
||||||
<Stack screenOptions={{}}>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="(auth)/(tabs)"
|
name="(auth)/(tabs)"
|
||||||
options={{
|
options={{
|
||||||
@@ -136,6 +143,8 @@ export default function RootLayout() {
|
|||||||
<CurrentlyPlayingBar />
|
<CurrentlyPlayingBar />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</JellyfinProvider>
|
</JellyfinProvider>
|
||||||
|
</ActionSheetProvider>
|
||||||
|
</JobQueueProvider>
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Input } from "@/components/common/Input";
|
|||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { KeyboardAvoidingView, Platform, View } from "react-native";
|
import { KeyboardAvoidingView, Platform, View } from "react-native";
|
||||||
@@ -18,6 +19,7 @@ const Login: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
const [serverURL, setServerURL] = useState<string>("");
|
const [serverURL, setServerURL] = useState<string>("");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -36,31 +38,15 @@ const Login: React.FC = () => {
|
|||||||
await login(credentials.username, credentials.password);
|
await login(credentials.username, credentials.password);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
const e = error as AxiosError;
|
||||||
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsedServerURL = useMemo(() => {
|
|
||||||
let parsedServerURL = serverURL.trim();
|
|
||||||
|
|
||||||
if (parsedServerURL) {
|
|
||||||
parsedServerURL = parsedServerURL.endsWith("/")
|
|
||||||
? parsedServerURL.replace("/", "")
|
|
||||||
: parsedServerURL;
|
|
||||||
parsedServerURL = parsedServerURL.startsWith("http")
|
|
||||||
? parsedServerURL
|
|
||||||
: "http://" + parsedServerURL;
|
|
||||||
|
|
||||||
return parsedServerURL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
|
||||||
}, [serverURL]);
|
|
||||||
|
|
||||||
const handleConnect = (url: string) => {
|
const handleConnect = (url: string) => {
|
||||||
setServer({ address: url });
|
setServer({ address: url.trim() });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (api?.basePath) {
|
if (api?.basePath) {
|
||||||
@@ -122,6 +108,8 @@ const Login: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Text className="text-red-600 mb-2">{error}</Text>
|
||||||
|
|
||||||
<Button onPress={handleLogin} loading={loading}>
|
<Button onPress={handleLogin} loading={loading}>
|
||||||
Log in
|
Log in
|
||||||
</Button>
|
</Button>
|
||||||
@@ -150,9 +138,7 @@ const Login: React.FC = () => {
|
|||||||
textContentType="URL"
|
textContentType="URL"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<Button onPress={() => handleConnect(parsedServerURL)}>
|
<Button onPress={() => handleConnect(serverURL)}>Connect</Button>
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|||||||
BIN
assets/Download_on_the_App_Store_Badge.png
Normal file
BIN
assets/Download_on_the_App_Store_Badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
assets/images/rotten-tomatoes.png
Normal file
BIN
assets/images/rotten-tomatoes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
80
components/AudioTrackSelector.tsx
Normal file
80
components/AudioTrackSelector.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
components/Badge.tsx
Normal file
36
components/Badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "./common/Text";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
text?: string | number | null;
|
||||||
|
variant?: "gray" | "purple";
|
||||||
|
iconLeft?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Badge: React.FC<Props> = ({
|
||||||
|
iconLeft,
|
||||||
|
text,
|
||||||
|
variant = "purple",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...props}
|
||||||
|
className={`
|
||||||
|
rounded p-1 shrink grow-0 self-start flex flex-row items-center px-1.5
|
||||||
|
${variant === "purple" && "bg-purple-600"}
|
||||||
|
${variant === "gray" && "bg-neutral-800"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{iconLeft && <View className="mr-1">{iconLeft}</View>}
|
||||||
|
<Text
|
||||||
|
className={`
|
||||||
|
text-xs
|
||||||
|
${variant === "purple" && "text-white"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,10 @@ const BITRATES: Bitrate[] = [
|
|||||||
key: "Max",
|
key: "Max",
|
||||||
value: undefined,
|
value: undefined,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "8 Mb/s",
|
||||||
|
value: 8000000,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "4 Mb/s",
|
key: "4 Mb/s",
|
||||||
value: 4000000,
|
value: 4000000,
|
||||||
@@ -25,22 +29,30 @@ const BITRATES: Bitrate[] = [
|
|||||||
key: "500 Kb/s",
|
key: "500 Kb/s",
|
||||||
value: 500000,
|
value: 500000,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "250 Kb/s",
|
||||||
|
value: 250000,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
interface Props extends React.ComponentProps<typeof View> {
|
||||||
onChange: (value: Bitrate) => void;
|
onChange: (value: Bitrate) => void;
|
||||||
selected: Bitrate;
|
selected: Bitrate;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const BitrateSelector: React.FC<Props> = ({ onChange, selected }) => {
|
export const BitrateSelector: React.FC<Props> = ({
|
||||||
|
onChange,
|
||||||
|
selected,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-between">
|
<View className="flex flex-row items-center justify-between" {...props}>
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
<View className="flex flex-col mb-2">
|
<View className="flex flex-col mb-2">
|
||||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||||
<View className="flex flex-row">
|
<View className="flex flex-row">
|
||||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
<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>
|
<Text>
|
||||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -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;
|
children?: string | ReactNode;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
color?: "purple" | "red" | "black";
|
color?: "purple" | "red" | "black";
|
||||||
iconRight?: ReactNode;
|
iconRight?: ReactNode;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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,
|
||||||
@@ -9,11 +10,11 @@ import {
|
|||||||
import GoogleCast from "react-native-google-cast";
|
import GoogleCast from "react-native-google-cast";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item?: BaseItemDto | null;
|
width?: number;
|
||||||
startTimeTicks?: number | null;
|
height?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Chromecast: React.FC<Props> = () => {
|
export const Chromecast: React.FC<Props> = ({ width = 48, height = 48 }) => {
|
||||||
const client = useRemoteMediaClient();
|
const client = useRemoteMediaClient();
|
||||||
const castDevice = useCastDevice();
|
const castDevice = useCastDevice();
|
||||||
const devices = useDevices();
|
const devices = useDevices();
|
||||||
@@ -30,5 +31,9 @@ export const Chromecast: React.FC<Props> = () => {
|
|||||||
})();
|
})();
|
||||||
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
}, [client, devices, castDevice, sessionManager, discoveryManager]);
|
||||||
|
|
||||||
return <CastButton style={{ tintColor: "white", height: 48, width: 48 }} />;
|
return (
|
||||||
|
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||||
|
<CastButton style={{ tintColor: "white", height, width }} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
width: `${progress}%`,
|
width: `${progress}%`,
|
||||||
}}
|
}}
|
||||||
className={`absolute bottom-0 left-0 h-1 bg-red-600 w-full`}
|
className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
|
||||||
></View>
|
></View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,18 +7,13 @@ import {
|
|||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCastDevice, useRemoteMediaClient } from "react-native-google-cast";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import ios12 from "@/utils/profiles/ios12";
|
|
||||||
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
import { reportPlaybackProgress } from "@/utils/jellyfin/playstate/reportPlaybackProgress";
|
||||||
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
import { reportPlaybackStopped } from "@/utils/jellyfin/playstate/reportPlaybackStopped";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -28,6 +23,9 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useRouter, useSegments } from "expo-router";
|
import { useRouter, useSegments } from "expo-router";
|
||||||
import { BlurView } from "expo-blur";
|
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<{
|
export const currentlyPlayingItemAtom = atom<{
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -35,13 +33,10 @@ export const currentlyPlayingItemAtom = atom<{
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
export const CurrentlyPlayingBar: React.FC = () => {
|
export const CurrentlyPlayingBar: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
const [cp, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||||
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
|
|
||||||
@@ -173,13 +168,24 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
[item],
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const backdropUrl = useMemo(
|
||||||
|
() =>
|
||||||
|
getBackdropUrl({
|
||||||
|
api,
|
||||||
|
item,
|
||||||
|
quality: 70,
|
||||||
|
width: 200,
|
||||||
|
}),
|
||||||
|
[item],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cp?.playbackUrl) {
|
if (cp?.playbackUrl) {
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
}, [cp?.playbackUrl]);
|
}, [cp?.playbackUrl]);
|
||||||
|
|
||||||
if (!cp) return null;
|
if (!cp || !api) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -203,31 +209,67 @@ export const CurrentlyPlayingBar: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
videoRef.current?.presentFullscreenPlayer();
|
videoRef.current?.presentFullscreenPlayer();
|
||||||
}}
|
}}
|
||||||
className="aspect-video h-full bg-neutral-800 rounded-md overflow-hidden"
|
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||||
|
${item?.Type === "Audio" ? "aspect-square" : "aspect-video"}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
{cp.playbackUrl && (
|
{cp.playbackUrl && (
|
||||||
<Video
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
allowsExternalPlayback
|
||||||
style={{ width: "100%", height: "100%" }}
|
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={{
|
source={{
|
||||||
uri: cp.playbackUrl,
|
uri: cp.playbackUrl,
|
||||||
isNetwork: true,
|
isNetwork: true,
|
||||||
startPosition,
|
startPosition,
|
||||||
|
headers: getAuthHeaders(api),
|
||||||
}}
|
}}
|
||||||
controls={false}
|
|
||||||
ref={videoRef}
|
|
||||||
onBuffer={(e) =>
|
onBuffer={(e) =>
|
||||||
e.isBuffering ? console.log("Buffering...") : null
|
e.isBuffering ? console.log("Buffering...") : null
|
||||||
}
|
}
|
||||||
onProgress={(e) => onProgress(e)}
|
onPlaybackStateChanged={(e) => {
|
||||||
paused={paused}
|
if (e.isPlaying) {
|
||||||
onFullscreenPlayerDidDismiss={() => {
|
setPaused(false);
|
||||||
play();
|
} else if (e.isSeeking) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
pause();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
progressUpdateInterval={1000}
|
||||||
|
onError={(e) => {
|
||||||
|
console.log(e);
|
||||||
|
writeToLog(
|
||||||
|
"ERROR",
|
||||||
|
"Video playback error: " + JSON.stringify(e),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
renderLoader={
|
renderLoader={
|
||||||
|
item?.Type !== "Audio" && (
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className="flex flex-col items-center justify-center h-full">
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
</View>
|
</View>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
|
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 { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|
||||||
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
|
||||||
|
|
||||||
type DownloadProps = {
|
type DownloadProps = {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -26,119 +24,114 @@ export const DownloadItem: React.FC<DownloadProps> = ({
|
|||||||
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 { downloadMedia, isDownloading, error, cancelDownload } =
|
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
||||||
useDownloadMedia(api, user?.Id);
|
|
||||||
|
|
||||||
const { startRemuxing, cancelRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
|
||||||
|
|
||||||
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 downloadFile = useCallback(async () => {
|
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
|
||||||
if (!playbackInfo) return;
|
queryKey: ["downloaded", item.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!item.Id) return false;
|
||||||
|
|
||||||
const source = playbackInfo.MediaSources?.[0];
|
|
||||||
|
|
||||||
if (source?.SupportsDirectPlay && item.CanDownload) {
|
|
||||||
downloadMedia(item);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"Direct play not supported thus the file cannot be downloaded",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [item, user, playbackInfo]);
|
|
||||||
|
|
||||||
const [downloaded, setDownloaded] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const data: BaseItemDto[] = JSON.parse(
|
const data: BaseItemDto[] = JSON.parse(
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.find((d) => d.Id === item.Id)) setDownloaded(true);
|
return data.some((d) => d.Id === item.Id);
|
||||||
})();
|
},
|
||||||
}, [process]);
|
enabled: !!item.Id,
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading || isLoadingDownloaded) {
|
||||||
return <ActivityIndicator size={"small"} color={"white"} />;
|
return (
|
||||||
|
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||||
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
||||||
return (
|
return (
|
||||||
<View style={{ opacity: 0.5 }}>
|
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
<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 onPress={() => {}} style={{ opacity: 0.5 }}>
|
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
{process ? (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
cancelRemuxing();
|
router.push("/downloads");
|
||||||
}}
|
}}
|
||||||
className="flex flex-row items-center"
|
|
||||||
>
|
>
|
||||||
|
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||||
{process.progress === 0 ? (
|
{process.progress === 0 ? (
|
||||||
<ActivityIndicator size={"small"} color={"white"} />
|
<ActivityIndicator size={"small"} color={"white"} />
|
||||||
) : (
|
) : (
|
||||||
<View className="relative">
|
|
||||||
<View className="-rotate-45">
|
<View className="-rotate-45">
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
size={28}
|
size={24}
|
||||||
fill={process.progress}
|
fill={process.progress}
|
||||||
width={4}
|
width={4}
|
||||||
tintColor="#3498db"
|
tintColor="#9334E9"
|
||||||
backgroundColor="#bdc3c7"
|
backgroundColor="#bdc3c7"
|
||||||
/>
|
/>
|
||||||
</View>
|
</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>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{process?.speed && process.speed > 0 ? (
|
if (queue.some((i) => i.id === item.Id)) {
|
||||||
<View className="ml-2">
|
return (
|
||||||
<Text>{process.speed.toFixed(2)}x</Text>
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/downloads");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
||||||
|
<Ionicons name="hourglass" size={24} color="white" />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : downloaded ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push(
|
|
||||||
`/(auth)/player/offline/page?url=${item.Id}.mp4&itemId=${item.Id}`,
|
|
||||||
);
|
);
|
||||||
}}
|
}
|
||||||
>
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#16a34a" />
|
if (downloaded) {
|
||||||
</TouchableOpacity>
|
return (
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// downloadFile();
|
router.push("/downloads");
|
||||||
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" />
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ 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">
|
||||||
@@ -17,9 +28,8 @@ 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${item.SeasonName?.replace(
|
{`S${seasonNameToIndex(
|
||||||
"Season ",
|
item?.SeasonName,
|
||||||
""
|
|
||||||
)}:E${item.IndexNumber?.toString()}`}{" "}
|
)}:E${item.IndexNumber?.toString()}`}{" "}
|
||||||
{item.Name}
|
{item.Name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
56
components/NewVideoPlayer.tsx
Normal file
56
components/NewVideoPlayer.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useVideoPlayer, VideoView } from "expo-video";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
PixelRatio,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Button,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
const videoSource =
|
||||||
|
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
videoSource: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NewVideoPlayer: React.FC<Props> = ({ videoSource }) => {
|
||||||
|
const ref = useRef<VideoView | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(true);
|
||||||
|
const player = useVideoPlayer(videoSource, (player) => {
|
||||||
|
player.loop = true;
|
||||||
|
player.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = player.addListener("playingChange", (isPlaying) => {
|
||||||
|
setIsPlaying(isPlaying);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [player]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
ref.current?.enterFullscreen();
|
||||||
|
}}
|
||||||
|
className={`relative h-full bg-neutral-800 rounded-md overflow-hidden
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<VideoView
|
||||||
|
ref={ref}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
player={player}
|
||||||
|
allowsFullscreen
|
||||||
|
allowsPictureInPicture
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -30,7 +30,7 @@ type VideoPlayerProps = {
|
|||||||
onChangePlaybackURL: (url: string | null) => void;
|
onChangePlaybackURL: (url: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
export const OldVideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
itemId,
|
itemId,
|
||||||
onChangePlaybackURL,
|
onChangePlaybackURL,
|
||||||
}) => {
|
}) => {
|
||||||
38
components/OverviewText.tsx
Normal file
38
components/OverviewText.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { tc } from "@/utils/textTools";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
text?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT = 140;
|
||||||
|
|
||||||
|
export const OverviewText: React.FC<Props> = ({ text, ...props }) => {
|
||||||
|
const [limit, setLimit] = useState(LIMIT);
|
||||||
|
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
if (text.length > LIMIT)
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
setLimit((prev) => (prev === LIMIT ? text.length : LIMIT))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View {...props} className="">
|
||||||
|
<Text>{tc(text, limit)}</Text>
|
||||||
|
<Text className="text-purple-600 mt-1">
|
||||||
|
{limit === LIMIT ? "Show more" : "Show less"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ 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;
|
||||||
|
|
||||||
@@ -72,6 +73,15 @@ 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}
|
||||||
@@ -89,7 +99,9 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
{headerImage}
|
{headerImage}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<View className="flex-1 overflow-hidden bg-black">{children}</View>
|
<View className="flex-1 overflow-hidden bg-black pb-24">
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,63 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { currentlyPlayingItemAtom } from "./CurrentlyPlayingBar";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
import { runtimeTicksToMinutes } from "@/utils/time";
|
||||||
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
type Props = {
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
onPress: () => void;
|
onPress: (type?: "cast" | "device") => void;
|
||||||
chromecastReady: boolean;
|
chromecastReady: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const PlayButton: React.FC<Props> = ({
|
export const PlayButton: React.FC<Props> = ({
|
||||||
item,
|
item,
|
||||||
onPress,
|
onPress,
|
||||||
chromecastReady,
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onPress={onPress}
|
onPress={_onPress}
|
||||||
iconRight={
|
iconRight={
|
||||||
chromecastReady ? (
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Feather name="cast" size={20} color="white" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="play-circle" size={24} color="white" />
|
<Ionicons name="play-circle" size={24} color="white" />
|
||||||
)
|
{chromecastReady && <Feather name="cast" size={22} color="white" />}
|
||||||
|
</View>
|
||||||
}
|
}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
{runtimeTicksToMinutes(item?.RunTimeTicks)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
invalidateQueries();
|
invalidateQueries();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="checkmark-circle" size={26} color="white" />
|
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||||
|
<Ionicons name="checkmark-circle" size={30} color="white" />
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -61,7 +63,9 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
invalidateQueries();
|
invalidateQueries();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="checkmark-circle-outline" size={26} color="white" />
|
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
||||||
|
<Ionicons name="checkmark-circle-outline" size={30} color="white" />
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
41
components/Ratings.tsx
Normal file
41
components/Ratings.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Badge } from "./Badge";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Ratings: React.FC<Props> = ({ item }) => {
|
||||||
|
return (
|
||||||
|
<View className="flex flex-row items-center justify-center mt-2 space-x-2">
|
||||||
|
{item.OfficialRating && (
|
||||||
|
<Badge text={item.OfficialRating} variant="gray" />
|
||||||
|
)}
|
||||||
|
{item.CommunityRating && (
|
||||||
|
<Badge
|
||||||
|
text={item.CommunityRating}
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={<Ionicons name="star" size={14} color="gold" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.CriticRating && (
|
||||||
|
<Badge
|
||||||
|
text={item.CriticRating}
|
||||||
|
variant="gray"
|
||||||
|
iconLeft={
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/images/rotten-tomatoes.png")}
|
||||||
|
style={{
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
92
components/SubtitleTrackSelector.tsx
Normal file
92
components/SubtitleTrackSelector.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
components/_template.tsx
Normal file
12
components/_template.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {}
|
||||||
|
|
||||||
|
export const TitleHeader: React.FC<Props> = ({ ...props }) => {
|
||||||
|
return (
|
||||||
|
<View {...props}>
|
||||||
|
<Text></Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,22 @@ export const EpisodeCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
const { deleteFile } = useFiles();
|
const { deleteFile } = useFiles();
|
||||||
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
const [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||||
|
|
||||||
|
// const fetchFileSize = async () => {
|
||||||
|
// try {
|
||||||
|
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||||
|
// const info = await FileSystem.getInfoAsync(filePath);
|
||||||
|
// return info.exists ? info.size : null;
|
||||||
|
// } catch (e) {
|
||||||
|
// console.log(e);
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const { data: fileSize } = useQuery({
|
||||||
|
// queryKey: ["fileSize", item?.Id],
|
||||||
|
// queryFn: fetchFileSize,
|
||||||
|
// });
|
||||||
|
|
||||||
const openFile = useCallback(() => {
|
const openFile = useCallback(() => {
|
||||||
setCp({
|
setCp({
|
||||||
item,
|
item,
|
||||||
@@ -43,6 +59,12 @@ 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
|
||||||
|
|||||||
@@ -9,11 +9,23 @@ import { useCallback } from "react";
|
|||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { currentlyPlayingItemAtom } from "../CurrentlyPlayingBar";
|
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 [_, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||||
|
|
||||||
|
// const fetchFileSize = async () => {
|
||||||
|
// const filePath = `${FileSystem.documentDirectory}/${item.Id}.mp4`;
|
||||||
|
// const info = await FileSystem.getInfoAsync(filePath);
|
||||||
|
// return info.exists ? info.size : null;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const { data: fileSize } = useQuery({
|
||||||
|
// queryKey: ["fileSize", item?.Id],
|
||||||
|
// queryFn: fetchFileSize,
|
||||||
|
// });
|
||||||
|
|
||||||
const openFile = useCallback(() => {
|
const openFile = useCallback(() => {
|
||||||
setCp({
|
setCp({
|
||||||
item,
|
item,
|
||||||
@@ -41,11 +53,17 @@ export const MovieCard: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
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-row items-center justify-between">
|
<View className="flex flex-col">
|
||||||
<Text className=" text-xs opacity-50">{item.ProductionYear}</Text>
|
<Text className=" text-xs opacity-50">{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>
|
||||||
|
|||||||
21
components/movies/MoviesTitleHeader.tsx
Normal file
21
components/movies/MoviesTitleHeader.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className="flex flex-row items-center self-center px-4">
|
||||||
|
<Text className="text-center font-bold text-2xl mr-2">
|
||||||
|
{item?.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
107
components/series/NextEpisodeButton.tsx
Normal file
107
components/series/NextEpisodeButton.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
|
item: BaseItemDto;
|
||||||
|
type?: "next" | "previous";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NextEpisodeButton: React.FC<Props> = ({
|
||||||
|
item,
|
||||||
|
type = "next",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
|
||||||
|
// const { data: seasons } = useQuery({
|
||||||
|
// queryKey: ["seasons", item.SeriesId],
|
||||||
|
// queryFn: async () => {
|
||||||
|
// if (
|
||||||
|
// !api ||
|
||||||
|
// !user?.Id ||
|
||||||
|
// !item?.Id ||
|
||||||
|
// !item?.SeriesId ||
|
||||||
|
// !item?.IndexNumber
|
||||||
|
// )
|
||||||
|
// return [];
|
||||||
|
|
||||||
|
// const response = await getItemsApi(api).getItems({
|
||||||
|
// parentId: item?.SeriesId,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// console.log("seasons ~", type, response.data);
|
||||||
|
|
||||||
|
// return (response.data.Items as BaseItemDto[]) ?? [];
|
||||||
|
// },
|
||||||
|
// enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const nextSeason = useMemo(() => {
|
||||||
|
// if (!seasons) return null;
|
||||||
|
// const currentSeasonIndex = seasons.findIndex(
|
||||||
|
// (season) => season.Id === item.SeasonId,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (currentSeasonIndex === seasons.length - 1) return null;
|
||||||
|
|
||||||
|
// return seasons[currentSeasonIndex + 1];
|
||||||
|
// }, [seasons]);
|
||||||
|
|
||||||
|
const { data: nextEpisode } = useQuery({
|
||||||
|
queryKey: ["nextEpisode", item.Id, item.ParentId, type],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (
|
||||||
|
!api ||
|
||||||
|
!user?.Id ||
|
||||||
|
!item?.Id ||
|
||||||
|
!item?.ParentId ||
|
||||||
|
!item?.IndexNumber
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const response = await getItemsApi(api).getItems({
|
||||||
|
parentId: item?.ParentId,
|
||||||
|
limit: 1,
|
||||||
|
startIndex: type === "next" ? item.IndexNumber : item.IndexNumber - 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("NextEpisode ~", type, response.data);
|
||||||
|
|
||||||
|
return (response.data.Items?.[0] as BaseItemDto) || null;
|
||||||
|
},
|
||||||
|
enabled: Boolean(api && user?.Id && item?.Id && item.SeasonId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
if (!nextEpisode) return true;
|
||||||
|
if (nextEpisode.Id === item.Id) return true;
|
||||||
|
return false;
|
||||||
|
}, [nextEpisode, type]);
|
||||||
|
|
||||||
|
if (item.Type !== "Episode") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onPress={() => router.replace(`/items/${nextEpisode?.Id}/page`)}
|
||||||
|
className={`h-12 aspect-square`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{type === "next" ? (
|
||||||
|
<Ionicons name="chevron-forward" size={24} color="white" />
|
||||||
|
) : (
|
||||||
|
<Ionicons name="chevron-back" size={24} color="white" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
components/series/SeriesTitleHeader.tsx
Normal file
37
components/series/SeriesTitleHeader.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
|
||||||
|
interface Props extends ViewProps {
|
||||||
|
item: BaseItemDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SeriesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push(`/(auth)/series/${item.SeriesId}/page`)}
|
||||||
|
>
|
||||||
|
<Text className="text-center opacity-50">{item?.SeriesName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View className="flex flex-row items-center self-center px-4">
|
||||||
|
<Text className="text-center font-bold text-2xl mr-2">
|
||||||
|
{item?.Name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<View className="flex flex-row items-center self-center">
|
||||||
|
<TouchableOpacity onPress={() => {}}>
|
||||||
|
<Text className="text-center opacity-50">{item?.SeasonName}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-center opacity-50 mx-2">{"—"}</Text>
|
||||||
|
<Text className="text-center opacity-50">
|
||||||
|
{`Episode ${item.IndexNumber}`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,5 +12,5 @@ export const Colors = {
|
|||||||
tint: tintColorDark,
|
tint: tintColorDark,
|
||||||
icon: "#9BA1A6",
|
icon: "#9BA1A6",
|
||||||
tabIconDefault: "#9BA1A6",
|
tabIconDefault: "#9BA1A6",
|
||||||
tabIconSelected: "#EE4B2B",
|
tabIconSelected: "#9333ea",
|
||||||
};
|
};
|
||||||
|
|||||||
4
eas.json
4
eas.json
@@ -21,13 +21,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"channel": "0.3.1",
|
"channel": "0.4.2",
|
||||||
"android": {
|
"android": {
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"production-apk": {
|
"production-apk": {
|
||||||
"channel": "0.3.1",
|
"channel": "0.4.2",
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk",
|
"buildType": "apk",
|
||||||
"image": "latest"
|
"image": "latest"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
||||||
const command = `-y -fflags +genpts -i ${url} -c copy -bufsize 10M -max_muxing_queue_size 4096 ${output}`;
|
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
||||||
|
|
||||||
const startRemuxing = useCallback(async () => {
|
const startRemuxing = useCallback(async () => {
|
||||||
writeToLog(
|
writeToLog(
|
||||||
@@ -54,7 +54,10 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await FFmpegKit.executeAsync(command, async (session) => {
|
// 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();
|
const returnCode = await session.getReturnCode();
|
||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
if (returnCode.isValueSuccess()) {
|
||||||
@@ -63,19 +66,26 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
|||||||
"INFO",
|
"INFO",
|
||||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
||||||
);
|
);
|
||||||
|
resolve();
|
||||||
} else if (returnCode.isValueError()) {
|
} else if (returnCode.isValueError()) {
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"ERROR",
|
"ERROR",
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||||
);
|
);
|
||||||
|
reject(new Error("Remuxing failed")); // Reject the promise on error
|
||||||
} else if (returnCode.isValueCancel()) {
|
} else if (returnCode.isValueCancel()) {
|
||||||
writeToLog(
|
writeToLog(
|
||||||
"INFO",
|
"INFO",
|
||||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
||||||
);
|
);
|
||||||
|
resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to remux:", error);
|
console.error("Failed to remux:", error);
|
||||||
@@ -84,6 +94,7 @@ export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
|||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
||||||
);
|
);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
|
throw error; // Re-throw the error to propagate it to the caller
|
||||||
}
|
}
|
||||||
}, [output, item, command, setProgress]);
|
}, [output, item, command, setProgress]);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
"@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",
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
setJellyfin(
|
setJellyfin(
|
||||||
() =>
|
() =>
|
||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "1.0.0" },
|
clientInfo: { name: "Streamyfin", version: "0.4.2" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
14
providers/JobQueueProvider.tsx
Normal file
14
providers/JobQueueProvider.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React, { createContext } from "react";
|
||||||
|
import { useJobProcessor } from "@/utils/atoms/queue";
|
||||||
|
|
||||||
|
const JobQueueContext = createContext(null);
|
||||||
|
|
||||||
|
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
useJobProcessor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
utils/atoms/queue.ts
Normal file
55
utils/atoms/queue.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { atom, useAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: string;
|
||||||
|
item: BaseItemDto;
|
||||||
|
execute: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queueAtom = atom<Job[]>([]);
|
||||||
|
export const isProcessingAtom = atom(false);
|
||||||
|
|
||||||
|
export const queueActions = {
|
||||||
|
enqueue: (queue: Job[], setQueue: (update: Job[]) => void, job: Job) => {
|
||||||
|
const updatedQueue = [...queue, job];
|
||||||
|
console.info("Enqueueing job", job, updatedQueue);
|
||||||
|
setQueue(updatedQueue);
|
||||||
|
},
|
||||||
|
processJob: async (
|
||||||
|
queue: Job[],
|
||||||
|
setQueue: (update: Job[]) => void,
|
||||||
|
setProcessing: (processing: boolean) => void,
|
||||||
|
) => {
|
||||||
|
const [job, ...rest] = queue;
|
||||||
|
setQueue(rest);
|
||||||
|
|
||||||
|
console.info("Processing job", job);
|
||||||
|
|
||||||
|
setProcessing(true);
|
||||||
|
await job.execute();
|
||||||
|
console.info("Job done", job);
|
||||||
|
setProcessing(false);
|
||||||
|
},
|
||||||
|
clear: (
|
||||||
|
setQueue: (update: Job[]) => void,
|
||||||
|
setProcessing: (processing: boolean) => void,
|
||||||
|
) => {
|
||||||
|
setQueue([]);
|
||||||
|
setProcessing(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useJobProcessor = () => {
|
||||||
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
|
const [isProcessing, setProcessing] = useAtom(isProcessingAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.info("Queue changed", queue, isProcessing);
|
||||||
|
if (queue.length > 0 && !isProcessing) {
|
||||||
|
console.info("Processing queue", queue);
|
||||||
|
queueActions.processJob(queue, setQueue, setProcessing);
|
||||||
|
}
|
||||||
|
}, [queue, isProcessing, setQueue, setProcessing]);
|
||||||
|
};
|
||||||
@@ -14,6 +14,8 @@ 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;
|
||||||
@@ -22,6 +24,8 @@ 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;
|
||||||
@@ -40,6 +44,8 @@ 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: {
|
||||||
@@ -58,8 +64,28 @@ export const getStreamUrl = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mediaSource.SupportsDirectPlay) {
|
if (mediaSource.SupportsDirectPlay) {
|
||||||
console.log("Using direct stream!");
|
if (item.MediaType === "Video") {
|
||||||
|
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!");
|
||||||
|
|||||||
7
utils/textTools.ts
Normal file
7
utils/textTools.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user