mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-04 04:58:30 +01:00
Compare commits
9 Commits
v0.6.1
...
feat/tv-os
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7fd382f2 | ||
|
|
d8201aa1fc | ||
|
|
dec45056f3 | ||
|
|
1d41b7080f | ||
|
|
83a09ad74a | ||
|
|
65ac147441 | ||
|
|
6a070cfbe0 | ||
|
|
9d1a03a5f2 | ||
|
|
08b28f7599 |
@@ -26,10 +26,6 @@ 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.
|
||||||
|
|
||||||
## Roadmap for V1
|
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
|
||||||
|
|
||||||
## Get it now
|
## Get it now
|
||||||
|
|
||||||
<div style="display:flex;">
|
<div style="display:flex;">
|
||||||
|
|||||||
37
app.json
37
app.json
@@ -46,15 +46,9 @@
|
|||||||
"favicon": "./assets/images/favicon.png"
|
"favicon": "./assets/images/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"@config-plugins/ffmpeg-kit-react-native",
|
|
||||||
[
|
|
||||||
"react-native-google-cast",
|
|
||||||
{
|
|
||||||
"useDefaultExpandedMediaControls": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
@@ -67,35 +61,6 @@
|
|||||||
"useExoplayerDash": false
|
"useExoplayerDash": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-build-properties",
|
|
||||||
{
|
|
||||||
"ios": {
|
|
||||||
"deploymentTarget": "14.0"
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"minSdkVersion": 24,
|
|
||||||
"usesCleartextTraffic": true,
|
|
||||||
"packagingOptions": {
|
|
||||||
"jniLibs": {
|
|
||||||
"useLegacyPackaging": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-screen-orientation",
|
|
||||||
{
|
|
||||||
"initialOrientation": "DEFAULT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-sensors",
|
|
||||||
{
|
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { router, Tabs } from "expo-router";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import * as NavigationBar from "expo-navigation-bar";
|
|
||||||
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
import { TabBarIcon } from "@/components/navigation/TabBarIcon";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
|
||||||
import { Feather } from "@expo/vector-icons";
|
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { StyleSheet } from "react-native";
|
import * as NavigationBar from "expo-navigation-bar";
|
||||||
|
import { Tabs } from "expo-router";
|
||||||
|
import React, { useEffect } from "react";
|
||||||
|
import { Platform, StyleSheet } from "react-native";
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
@@ -17,21 +16,8 @@ export default function IndexLayout() {
|
|||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios" ? true : false,
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerLeft: () => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={{
|
|
||||||
marginRight: Platform.OS === "android" ? 17 : 0,
|
|
||||||
}}
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/(auth)/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Feather name="download" color={"white"} size={22} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
),
|
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className="flex flex-row items-center space-x-2">
|
||||||
<Chromecast />
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
router.push("/(auth)/settings");
|
router.push("/(auth)/settings");
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
|
|||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +29,6 @@ export default function index() {
|
|||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [settings, _] = useSettings();
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||||
|
|
||||||
@@ -171,11 +169,7 @@ export default function index() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: mediaListCollections } = useQuery({
|
const { data: mediaListCollections } = useQuery({
|
||||||
queryKey: [
|
queryKey: ["mediaListCollections-home", user?.Id],
|
||||||
"mediaListCollections-home",
|
|
||||||
user?.Id,
|
|
||||||
settings?.mediaListCollectionIds,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) return [];
|
if (!api || !user?.Id) return [];
|
||||||
|
|
||||||
@@ -187,16 +181,9 @@ export default function index() {
|
|||||||
includeItemTypes: ["BoxSet"],
|
includeItemTypes: ["BoxSet"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const ids =
|
return [];
|
||||||
response.data.Items?.filter(
|
|
||||||
(c) =>
|
|
||||||
c.Name !== "cf_carousel" &&
|
|
||||||
settings?.mediaListCollectionIds?.includes(c.Id!)
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
return ids;
|
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
enabled: !!api && !!user?.Id && false,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,21 +204,6 @@ export default function index() {
|
|||||||
return (
|
return (
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
No worries, you can still watch{"\n"}downloaded content.
|
|
||||||
</Text>
|
|
||||||
<View className="mt-4">
|
|
||||||
<Button
|
|
||||||
color="purple"
|
|
||||||
onPress={() => router.push("/(auth)/downloads")}
|
|
||||||
justify="center"
|
|
||||||
iconRight={
|
|
||||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Go to downloads
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -278,10 +250,6 @@ export default function index() {
|
|||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{mediaListCollections?.map((ml) => (
|
|
||||||
<MediaListSection key={ml.Id} collection={ml} />
|
|
||||||
))}
|
|
||||||
|
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
title="Recently Added in Movies"
|
title="Recently Added in Movies"
|
||||||
data={recentlyAddedInMovies}
|
data={recentlyAddedInMovies}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import { SongsList } from "@/components/music/SongsList";
|
import { SongsList } from "@/components/music/SongsList";
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||||
@@ -24,16 +23,6 @@ export default function page() {
|
|||||||
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: album } = useQuery({
|
const { data: album } = useQuery({
|
||||||
queryKey: ["album", albumId, artistId],
|
queryKey: ["album", albumId, artistId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { MovieCard } from "@/components/downloads/MovieCard";
|
|
||||||
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { FFmpegKit } from "ffmpeg-kit-react-native";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
const downloads: React.FC = () => {
|
|
||||||
const [process, setProcess] = useAtom(runningProcesses);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
|
|
||||||
const { data: downloadedFiles, isLoading } = useQuery({
|
|
||||||
queryKey: ["downloaded_files", process?.item.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
|
||||||
) as BaseItemDto[],
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const movies = useMemo(
|
|
||||||
() => downloadedFiles?.filter((f) => f.Type === "Movie") || [],
|
|
||||||
[downloadedFiles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupedBySeries = useMemo(() => {
|
|
||||||
const episodes = downloadedFiles?.filter((f) => f.Type === "Episode");
|
|
||||||
const series: { [key: string]: BaseItemDto[] } = {};
|
|
||||||
episodes?.forEach((e) => {
|
|
||||||
if (!series[e.SeriesName!]) series[e.SeriesName!] = [];
|
|
||||||
series[e.SeriesName!].push(e);
|
|
||||||
});
|
|
||||||
return Object.values(series);
|
|
||||||
}, [downloadedFiles]);
|
|
||||||
|
|
||||||
const eta = useMemo(() => {
|
|
||||||
const length = process?.item?.RunTimeTicks || 0;
|
|
||||||
|
|
||||||
if (!process?.speed || !process?.progress) return "";
|
|
||||||
|
|
||||||
const timeLeft =
|
|
||||||
(length - length * (process.progress / 100)) / process.speed;
|
|
||||||
|
|
||||||
return formatNumber(timeLeft / 10000);
|
|
||||||
}, [process]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<View className="h-full flex flex-col items-center justify-center -mt-6">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView>
|
|
||||||
<View className="px-4 py-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}`)}
|
|
||||||
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>
|
|
||||||
{process?.item ? (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => router.push(`/(auth)/items/${process.item.Id}`)}
|
|
||||||
className="relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between"
|
|
||||||
>
|
|
||||||
<View>
|
|
||||||
<Text className="font-semibold">{process.item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{process.item.Type}
|
|
||||||
</Text>
|
|
||||||
<View className="flex flex-row items-center space-x-2 mt-1 text-purple-600">
|
|
||||||
<Text className="text-xs">
|
|
||||||
{process.progress.toFixed(0)}%
|
|
||||||
</Text>
|
|
||||||
<Text className="text-xs">
|
|
||||||
{process.speed?.toFixed(2)}x
|
|
||||||
</Text>
|
|
||||||
<View>
|
|
||||||
<Text className="text-xs">ETA {eta}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
FFmpegKit.cancel();
|
|
||||||
setProcess(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={24} color="red" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
absolute bottom-0 left-0 h-1 bg-purple-600
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
width: process.progress
|
|
||||||
? `${Math.max(5, process.progress)}%`
|
|
||||||
: "5%",
|
|
||||||
}}
|
|
||||||
></View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
) : (
|
|
||||||
<Text className="opacity-50">No active downloads</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{movies.length > 0 && (
|
|
||||||
<View className="mb-4">
|
|
||||||
<View className="flex flex-row items-center justify-between mb-2">
|
|
||||||
<Text className="text-2xl font-bold">Movies</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{movies?.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{movies?.map((item: BaseItemDto) => (
|
|
||||||
<View className="mb-2 last:mb-0" key={item.Id}>
|
|
||||||
<MovieCard item={item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{groupedBySeries?.map((items: BaseItemDto[], index: number) => (
|
|
||||||
<SeriesCard items={items} key={items[0].SeriesId} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default downloads;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Format a number (Date.getTime) to a human readable string ex. 2m 34s
|
|
||||||
* @param {number} num - The number to format
|
|
||||||
*
|
|
||||||
* @returns {string} - The formatted string
|
|
||||||
*/
|
|
||||||
const formatNumber = (num: number) => {
|
|
||||||
const minutes = Math.floor(num / 60000);
|
|
||||||
const seconds = ((num % 60000) / 1000).toFixed(0);
|
|
||||||
return `${minutes}m ${seconds}s`;
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
fullScreenAtom,
|
fullScreenAtom,
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "@/components/CurrentlyPlayingBar";
|
} from "@/components/CurrentlyPlayingBar";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
import { PlayButton } from "@/components/PlayButton";
|
import { PlayButton } from "@/components/PlayButton";
|
||||||
@@ -25,7 +24,6 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
import ios from "@/utils/profiles/ios";
|
||||||
import native from "@/utils/profiles/native";
|
import native from "@/utils/profiles/native";
|
||||||
import old from "@/utils/profiles/old";
|
import old from "@/utils/profiles/old";
|
||||||
@@ -36,11 +34,6 @@ 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 { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
@@ -52,14 +45,10 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
|
|
||||||
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
const [, setPlaying] = useAtom(playingAtom);
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||||
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
@@ -100,7 +89,6 @@ const page: React.FC = () => {
|
|||||||
"playbackUrl",
|
"playbackUrl",
|
||||||
item?.Id,
|
item?.Id,
|
||||||
maxBitrate,
|
maxBitrate,
|
||||||
castDevice,
|
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
settings,
|
settings,
|
||||||
@@ -110,9 +98,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
let deviceProfile: any = ios;
|
let deviceProfile: any = ios;
|
||||||
|
|
||||||
if (castDevice?.deviceId) {
|
if (settings?.deviceProfile === "Native") {
|
||||||
deviceProfile = chromecastProfile;
|
|
||||||
} else if (settings?.deviceProfile === "Native") {
|
|
||||||
deviceProfile = native;
|
deviceProfile = native;
|
||||||
} else if (settings?.deviceProfile === "Old") {
|
} else if (settings?.deviceProfile === "Old") {
|
||||||
deviceProfile = old;
|
deviceProfile = old;
|
||||||
@@ -143,34 +129,13 @@ const page: React.FC = () => {
|
|||||||
async (type: "device" | "cast" = "device") => {
|
async (type: "device" | "cast" = "device") => {
|
||||||
if (!playbackUrl || !item) return;
|
if (!playbackUrl || !item) return;
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
setCurrentlyPlying({
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
item,
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
playbackUrl,
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
});
|
||||||
else {
|
setPlaying(true);
|
||||||
client.loadMedia({
|
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||||
mediaInfo: {
|
setFullscreen(true);
|
||||||
contentUrl: playbackUrl,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata: {
|
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCurrentlyPlying({
|
|
||||||
item,
|
|
||||||
playbackUrl,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
|
||||||
setFullscreen(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[playbackUrl, item, settings]
|
[playbackUrl, item, settings]
|
||||||
@@ -245,11 +210,6 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center mb-2">
|
<View className="flex flex-row justify-between items-center mb-2">
|
||||||
{playbackUrl ? (
|
|
||||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
|
||||||
) : (
|
|
||||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
|
||||||
)}
|
|
||||||
<PlayedStatus item={item} />
|
<PlayedStatus item={item} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -276,7 +236,7 @@ const page: React.FC = () => {
|
|||||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||||
<PlayButton
|
<PlayButton
|
||||||
item={item}
|
item={item}
|
||||||
chromecastReady={chromecastReady}
|
chromecastReady={false}
|
||||||
onPress={onPressPlay}
|
onPress={onPressPlay}
|
||||||
className="grow"
|
className="grow"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,13 +6,9 @@ import { clearLogs, readFromLog } from "@/utils/log";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const { logout } = useJellyfin();
|
||||||
const { deleteAllFiles } = useFiles();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -33,30 +29,14 @@ export default function settings() {
|
|||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<ListItem title="Server" subTitle={api?.basePath} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SettingToggles />
|
|
||||||
|
|
||||||
<View className="flex flex-col space-y-2">
|
<View className="flex flex-col space-y-2">
|
||||||
<Button color="black" onPress={logout}>
|
<Button color="black" onPress={logout}>
|
||||||
Log out
|
Log out
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
onPress={async () => {
|
|
||||||
await deleteAllFiles();
|
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete all downloaded files
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await clearLogs();
|
await clearLogs();
|
||||||
Haptics.notificationAsync(
|
|
||||||
Haptics.NotificationFeedbackType.Success,
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete all logs
|
Delete all logs
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||||
import { Chromecast } from "@/components/Chromecast";
|
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
import {
|
import {
|
||||||
currentlyPlayingItemAtom,
|
currentlyPlayingItemAtom,
|
||||||
playingAtom,
|
playingAtom,
|
||||||
} from "@/components/CurrentlyPlayingBar";
|
} from "@/components/CurrentlyPlayingBar";
|
||||||
import { DownloadItem } from "@/components/DownloadItem";
|
|
||||||
import { Loader } from "@/components/Loader";
|
import { Loader } from "@/components/Loader";
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
@@ -19,20 +17,14 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import ios from "@/utils/profiles/ios";
|
import ios from "@/utils/profiles/ios";
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
@@ -43,20 +35,8 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
const [, setPlaying] = useAtom(playingAtom);
|
||||||
|
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => (
|
|
||||||
<View className="">
|
|
||||||
<Chromecast />
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
|
||||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||||
useState<number>(0);
|
useState<number>(0);
|
||||||
@@ -113,7 +93,6 @@ const page: React.FC = () => {
|
|||||||
"playbackUrl",
|
"playbackUrl",
|
||||||
item?.Id,
|
item?.Id,
|
||||||
maxBitrate,
|
maxBitrate,
|
||||||
castDevice,
|
|
||||||
selectedAudioStream,
|
selectedAudioStream,
|
||||||
selectedSubtitleStream,
|
selectedSubtitleStream,
|
||||||
],
|
],
|
||||||
@@ -127,7 +106,7 @@ const page: React.FC = () => {
|
|||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
maxStreamingBitrate: maxBitrate.value,
|
maxStreamingBitrate: maxBitrate.value,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
deviceProfile: ios,
|
||||||
audioStreamIndex: selectedAudioStream,
|
audioStreamIndex: selectedAudioStream,
|
||||||
subtitleStreamIndex: selectedSubtitleStream,
|
subtitleStreamIndex: selectedSubtitleStream,
|
||||||
});
|
});
|
||||||
@@ -141,38 +120,16 @@ const page: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
|
|
||||||
const onPressPlay = useCallback(
|
const onPressPlay = useCallback(
|
||||||
async (type: "device" | "cast" = "device") => {
|
async (type: "device" | "cast" = "device") => {
|
||||||
if (!playbackUrl || !item) return;
|
if (!playbackUrl || !item) return;
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
setCp({
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
item,
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
playbackUrl,
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
});
|
||||||
else {
|
setPlaying(true);
|
||||||
client.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentUrl: playbackUrl,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata: {
|
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCp({
|
|
||||||
item,
|
|
||||||
playbackUrl,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[playbackUrl, item]
|
[playbackUrl, item]
|
||||||
);
|
);
|
||||||
@@ -221,14 +178,6 @@ const page: React.FC = () => {
|
|||||||
<MoviesTitleHeader item={item} />
|
<MoviesTitleHeader item={item} />
|
||||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex flex-row justify-between items-center w-full my-4">
|
|
||||||
{playbackUrl ? (
|
|
||||||
<DownloadItem item={item} playbackUrl={playbackUrl} />
|
|
||||||
) : (
|
|
||||||
<View className="h-12 aspect-square flex items-center justify-center"></View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<View className="flex flex-col p-4 w-full">
|
<View className="flex flex-col p-4 w-full">
|
||||||
<View className="flex flex-row items-center space-x-2 w-full">
|
<View className="flex flex-row items-center space-x-2 w-full">
|
||||||
@@ -251,7 +200,7 @@ const page: React.FC = () => {
|
|||||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||||
<PlayButton
|
<PlayButton
|
||||||
item={item}
|
item={item}
|
||||||
chromecastReady={chromecastReady}
|
chromecastReady={false}
|
||||||
onPress={onPressPlay}
|
onPress={onPressPlay}
|
||||||
className="grow"
|
className="grow"
|
||||||
/>
|
/>
|
||||||
|
|||||||
221
app/_layout.tsx
221
app/_layout.tsx
@@ -1,22 +1,19 @@
|
|||||||
|
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useFonts } from "expo-font";
|
import { useFonts } from "expo-font";
|
||||||
|
import { useKeepAwake } from "expo-keep-awake";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import "react-native-reanimated";
|
|
||||||
import * as ScreenOrientation from "expo-screen-orientation";
|
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { useEffect, useRef } from "react";
|
||||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
|
||||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import "react-native-reanimated";
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -48,8 +45,6 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
|
||||||
|
|
||||||
useKeepAwake();
|
useKeepAwake();
|
||||||
|
|
||||||
const queryClientRef = useRef<QueryClient>(
|
const queryClientRef = useRef<QueryClient>(
|
||||||
@@ -66,119 +61,99 @@ function Layout() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (settings?.autoRotate === true)
|
|
||||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
|
||||||
else
|
|
||||||
ScreenOrientation.lockAsync(
|
|
||||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
|
||||||
);
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<QueryClientProvider client={queryClientRef.current}>
|
<QueryClientProvider client={queryClientRef.current}>
|
||||||
<JobQueueProvider>
|
<ActionSheetProvider>
|
||||||
<ActionSheetProvider>
|
<BottomSheetModalProvider>
|
||||||
<BottomSheetModalProvider>
|
<JellyfinProvider>
|
||||||
<JellyfinProvider>
|
<StatusBar style="light" backgroundColor="#000" />
|
||||||
<StatusBar style="light" backgroundColor="#000" />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<Stack initialRouteName="/home">
|
||||||
<Stack initialRouteName="/home">
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/(tabs)"
|
||||||
name="(auth)/(tabs)"
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
headerShown: false,
|
title: "",
|
||||||
title: "",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/settings"
|
||||||
name="(auth)/settings"
|
options={{
|
||||||
options={{
|
headerShown: true,
|
||||||
headerShown: true,
|
title: "Settings",
|
||||||
title: "Settings",
|
headerStyle: { backgroundColor: "black" },
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShadowVisible: false,
|
||||||
headerShadowVisible: false,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/items/[id]"
|
||||||
name="(auth)/downloads"
|
options={{
|
||||||
options={{
|
title: "",
|
||||||
headerShown: true,
|
headerShown: false,
|
||||||
title: "Downloads",
|
}}
|
||||||
headerStyle: { backgroundColor: "black" },
|
/>
|
||||||
headerShadowVisible: false,
|
<Stack.Screen
|
||||||
}}
|
name="(auth)/collections/[collectionId]"
|
||||||
/>
|
options={{
|
||||||
<Stack.Screen
|
title: "",
|
||||||
name="(auth)/items/[id]"
|
headerShown: true,
|
||||||
options={{
|
headerStyle: { backgroundColor: "black" },
|
||||||
title: "",
|
headerShadowVisible: false,
|
||||||
headerShown: false,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/artists/page"
|
||||||
name="(auth)/collections/[collectionId]"
|
options={{
|
||||||
options={{
|
title: "",
|
||||||
title: "",
|
headerShown: true,
|
||||||
headerShown: true,
|
headerStyle: { backgroundColor: "black" },
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShadowVisible: false,
|
||||||
headerShadowVisible: false,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/artists/[artistId]/page"
|
||||||
name="(auth)/artists/page"
|
options={{
|
||||||
options={{
|
title: "",
|
||||||
title: "",
|
headerShown: true,
|
||||||
headerShown: true,
|
headerStyle: { backgroundColor: "black" },
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShadowVisible: false,
|
||||||
headerShadowVisible: false,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/albums/[albumId]"
|
||||||
name="(auth)/artists/[artistId]/page"
|
options={{
|
||||||
options={{
|
title: "",
|
||||||
title: "",
|
headerShown: true,
|
||||||
headerShown: true,
|
headerStyle: { backgroundColor: "black" },
|
||||||
headerStyle: { backgroundColor: "black" },
|
headerShadowVisible: false,
|
||||||
headerShadowVisible: false,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name="(auth)/songs/[songId]"
|
||||||
name="(auth)/albums/[albumId]"
|
options={{
|
||||||
options={{
|
title: "",
|
||||||
title: "",
|
headerShown: false,
|
||||||
headerShown: true,
|
}}
|
||||||
headerStyle: { backgroundColor: "black" },
|
/>
|
||||||
headerShadowVisible: false,
|
<Stack.Screen
|
||||||
}}
|
name="(auth)/series/[id]"
|
||||||
/>
|
options={{
|
||||||
<Stack.Screen
|
title: "",
|
||||||
name="(auth)/songs/[songId]"
|
headerShown: false,
|
||||||
options={{
|
}}
|
||||||
title: "",
|
/>
|
||||||
headerShown: false,
|
<Stack.Screen
|
||||||
}}
|
name="login"
|
||||||
/>
|
options={{ headerShown: false, title: "Login" }}
|
||||||
<Stack.Screen
|
/>
|
||||||
name="(auth)/series/[id]"
|
<Stack.Screen name="+not-found" />
|
||||||
options={{
|
</Stack>
|
||||||
title: "",
|
<CurrentlyPlayingBar />
|
||||||
headerShown: false,
|
</ThemeProvider>
|
||||||
}}
|
</JellyfinProvider>
|
||||||
/>
|
</BottomSheetModalProvider>
|
||||||
<Stack.Screen
|
</ActionSheetProvider>
|
||||||
name="login"
|
|
||||||
options={{ headerShown: false, title: "Login" }}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="+not-found" />
|
|
||||||
</Stack>
|
|
||||||
<CurrentlyPlayingBar />
|
|
||||||
</ThemeProvider>
|
|
||||||
</JellyfinProvider>
|
|
||||||
</BottomSheetModalProvider>
|
|
||||||
</ActionSheetProvider>
|
|
||||||
</JobQueueProvider>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -22,12 +21,12 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
const audioStreams = useMemo(
|
const audioStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedAudioSteam = useMemo(
|
const selectedAudioSteam = useMemo(
|
||||||
() => audioStreams?.find((x) => x.Index === selected),
|
() => audioStreams?.find((x) => x.Index === selected),
|
||||||
[audioStreams, selected],
|
[audioStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -36,45 +35,9 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-between" {...props}>
|
<View
|
||||||
<DropdownMenu.Root>
|
className="flex flex-row items-center justify-between"
|
||||||
<DropdownMenu.Trigger>
|
{...props}
|
||||||
<View className="flex flex-col mb-2">
|
></View>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
|
|
||||||
@@ -46,42 +45,9 @@ export const BitrateSelector: React.FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-between" {...props}>
|
<View
|
||||||
<DropdownMenu.Root>
|
className="flex flex-row items-center justify-between"
|
||||||
<DropdownMenu.Trigger>
|
{...props}
|
||||||
<View className="flex flex-col mb-2">
|
></View>
|
||||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
|
||||||
<View className="flex flex-row">
|
|
||||||
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>
|
|
||||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
|
||||||
{BITRATES?.map((b, index: number) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={index.toString()}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(b);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Loader } from "./Loader";
|
import { Loader } from "./Loader";
|
||||||
@@ -51,7 +50,6 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!loading && !disabled && onPress) {
|
if (!loading && !disabled && onPress) {
|
||||||
onPress();
|
onPress();
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useRemuxHlsToMp4 } from "@/hooks/useRemuxHlsToMp4";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { queueActions, queueAtom } from "@/utils/atoms/queue";
|
|
||||||
import { getPlaybackInfo } from "@/utils/jellyfin/media/getPlaybackInfo";
|
|
||||||
import Ionicons from "@expo/vector-icons/Ionicons";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { Loader } from "./Loader";
|
|
||||||
import ProgressCircle from "./ProgressCircle";
|
|
||||||
|
|
||||||
type DownloadProps = {
|
|
||||||
item: BaseItemDto;
|
|
||||||
playbackUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DownloadItem: React.FC<DownloadProps> = ({
|
|
||||||
item,
|
|
||||||
playbackUrl,
|
|
||||||
}) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const [process] = useAtom(runningProcesses);
|
|
||||||
const [queue, setQueue] = useAtom(queueAtom);
|
|
||||||
|
|
||||||
const { startRemuxing } = useRemuxHlsToMp4(playbackUrl, item);
|
|
||||||
|
|
||||||
const { data: playbackInfo, isLoading } = useQuery({
|
|
||||||
queryKey: ["playbackInfo", item.Id],
|
|
||||||
queryFn: async () => getPlaybackInfo(api, item.Id, user?.Id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: downloaded, isLoading: isLoadingDownloaded } = useQuery({
|
|
||||||
queryKey: ["downloaded", item.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!item.Id) return false;
|
|
||||||
|
|
||||||
const data: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]"
|
|
||||||
);
|
|
||||||
|
|
||||||
return data.some((d) => d.Id === item.Id);
|
|
||||||
},
|
|
||||||
enabled: !!item.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading || isLoadingDownloaded) {
|
|
||||||
return (
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playbackInfo?.MediaSources?.[0].SupportsDirectPlay === false) {
|
|
||||||
return (
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
|
||||||
<Ionicons name="cloud-download-outline" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process && process?.item.Id === item.Id) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
{process.progress === 0 ? (
|
|
||||||
<Loader />
|
|
||||||
) : (
|
|
||||||
<View className="-rotate-45">
|
|
||||||
<ProgressCircle
|
|
||||||
size={24}
|
|
||||||
fill={process.progress}
|
|
||||||
width={4}
|
|
||||||
tintColor="#9334E9"
|
|
||||||
backgroundColor="#bdc3c7"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queue.some((i) => i.id === item.Id)) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center opacity-50">
|
|
||||||
<Ionicons name="hourglass" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloaded) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
router.push("/downloads");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="cloud-download" size={26} color="#9333ea" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
queueActions.enqueue(queue, setQueue, {
|
|
||||||
id: item.Id!,
|
|
||||||
execute: async () => {
|
|
||||||
await startRemuxing();
|
|
||||||
},
|
|
||||||
item,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="rounded h-10 aspect-square flex items-center justify-center">
|
|
||||||
<Ionicons name="cloud-download-outline" size={26} color="white" />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -9,7 +9,6 @@ import Animated, {
|
|||||||
useScrollViewOffset,
|
useScrollViewOffset,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Chromecast } from "./Chromecast";
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 400;
|
const HEADER_HEIGHT = 400;
|
||||||
|
|
||||||
@@ -33,14 +32,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
|||||||
translateY: interpolate(
|
translateY: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scale: interpolate(
|
scale: interpolate(
|
||||||
scrollOffset.value,
|
scrollOffset.value,
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||||
[2, 1, 1],
|
[2, 1, 1]
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -73,15 +72,6 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View
|
|
||||||
className="absolute right-4 z-50 bg-black rounded-full p-0.5"
|
|
||||||
style={{
|
|
||||||
top: inset.top + 17,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Chromecast width={22} height={22} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{logo && (
|
{logo && (
|
||||||
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
|
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
|
||||||
{logo}
|
{logo}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
@@ -41,7 +40,6 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
{item.UserData?.Played ? (
|
{item.UserData?.Played ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
await markAsNotPlayed({
|
await markAsNotPlayed({
|
||||||
api: api,
|
api: api,
|
||||||
itemId: item?.Id,
|
itemId: item?.Id,
|
||||||
@@ -57,7 +55,6 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
await markAsPlayed({
|
await markAsPlayed({
|
||||||
api: api,
|
api: api,
|
||||||
item: item,
|
item: item,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "./common/Text";
|
import { Text } from "./common/Text";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
@@ -22,14 +21,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
const subtitleStreams = useMemo(
|
const subtitleStreams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
item.MediaSources?.[0].MediaStreams?.filter(
|
item.MediaSources?.[0].MediaStreams?.filter(
|
||||||
(x) => x.Type === "Subtitle",
|
(x) => x.Type === "Subtitle"
|
||||||
) ?? [],
|
) ?? [],
|
||||||
[item],
|
[item]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSubtitleSteam = useMemo(
|
const selectedSubtitleSteam = useMemo(
|
||||||
() => subtitleStreams.find((x) => x.Index === selected),
|
() => subtitleStreams.find((x) => x.Index === selected),
|
||||||
[subtitleStreams, selected],
|
[subtitleStreams, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,55 +43,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
|||||||
if (subtitleStreams.length === 0) return null;
|
if (subtitleStreams.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row items-center justify-between" {...props}>
|
<View
|
||||||
<DropdownMenu.Root>
|
className="flex flex-row items-center justify-between"
|
||||||
<DropdownMenu.Trigger>
|
{...props}
|
||||||
<View className="flex flex-col mb-2">
|
></View>
|
||||||
<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="">
|
|
||||||
{selectedSubtitleSteam
|
|
||||||
? tc(selectedSubtitleSteam?.DisplayTitle, 13)
|
|
||||||
: "None"}
|
|
||||||
</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>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={"-1"}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(-1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>None</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { Text } from "@/components/common/Text";
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
|
|
||||||
interface Props extends TouchableOpacityProps {
|
interface Props extends TouchableOpacityProps {
|
||||||
item: BaseItemDto;
|
item: BaseItemDto;
|
||||||
@@ -23,8 +22,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
|
|
||||||
if (item.Type === "Series") {
|
if (item.Type === "Series") {
|
||||||
router.push(`/series/${item.Id}`);
|
router.push(`/series/${item.Id}`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { TouchableOpacity } from "react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
import {
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
|
||||||
} from "../CurrentlyPlayingBar";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
interface EpisodeCardProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EpisodeCard component displays an episode with context menu options.
|
|
||||||
* @param {EpisodeCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered EpisodeCard component.
|
|
||||||
*/
|
|
||||||
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useFiles();
|
|
||||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles opening the file for playback.
|
|
||||||
*/
|
|
||||||
const handleOpenFile = useCallback(async () => {
|
|
||||||
setCurrentlyPlaying({
|
|
||||||
item,
|
|
||||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true)
|
|
||||||
setFullscreen(true);
|
|
||||||
}, [item, setCurrentlyPlaying, settings]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const contextMenuOptions = [
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
onSelect: handleDeleteFile,
|
|
||||||
destructive: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu.Root>
|
|
||||||
<ContextMenu.Trigger>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenFile}
|
|
||||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
|
||||||
>
|
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
|
||||||
<Text className="text-xs opacity-50">Episode {item.IndexNumber}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions
|
|
||||||
collisionPadding={10}
|
|
||||||
loop={false}
|
|
||||||
>
|
|
||||||
{contextMenuOptions.map((option) => (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key={option.label}
|
|
||||||
onSelect={option.onSelect}
|
|
||||||
destructive={option.destructive}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
|
||||||
{option.label}
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
))}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import React, { useCallback } from "react";
|
|
||||||
import { TouchableOpacity, View } from "react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import * as ContextMenu from "zeego/context-menu";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import * as Haptics from "expo-haptics";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useFiles } from "@/hooks/useFiles";
|
|
||||||
import { runtimeTicksToMinutes } from "@/utils/time";
|
|
||||||
import {
|
|
||||||
currentlyPlayingItemAtom,
|
|
||||||
fullScreenAtom,
|
|
||||||
playingAtom,
|
|
||||||
} from "../CurrentlyPlayingBar";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
|
|
||||||
interface MovieCardProps {
|
|
||||||
item: BaseItemDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MovieCard component displays a movie with context menu options.
|
|
||||||
* @param {MovieCardProps} props - The component props.
|
|
||||||
* @returns {React.ReactElement} The rendered MovieCard component.
|
|
||||||
*/
|
|
||||||
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
|
|
||||||
const { deleteFile } = useFiles();
|
|
||||||
const [, setCurrentlyPlaying] = useAtom(currentlyPlayingItemAtom);
|
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
|
||||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles opening the file for playback.
|
|
||||||
*/
|
|
||||||
const handleOpenFile = useCallback(() => {
|
|
||||||
console.log("Open movie file", item.Name);
|
|
||||||
setCurrentlyPlaying({
|
|
||||||
item,
|
|
||||||
playbackUrl: `${FileSystem.documentDirectory}/${item.Id}.mp4`,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
|
||||||
setFullscreen(true);
|
|
||||||
}
|
|
||||||
}, [item, setCurrentlyPlaying, setPlaying, settings]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles deleting the file with haptic feedback.
|
|
||||||
*/
|
|
||||||
const handleDeleteFile = useCallback(() => {
|
|
||||||
if (item.Id) {
|
|
||||||
deleteFile(item.Id);
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
|
||||||
}, [deleteFile, item.Id]);
|
|
||||||
|
|
||||||
const contextMenuOptions = [
|
|
||||||
{
|
|
||||||
label: "Delete",
|
|
||||||
onSelect: handleDeleteFile,
|
|
||||||
destructive: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu.Root>
|
|
||||||
<ContextMenu.Trigger>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={handleOpenFile}
|
|
||||||
className="bg-neutral-900 border border-neutral-800 rounded-2xl p-4"
|
|
||||||
>
|
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="text-xs opacity-50">{item.ProductionYear}</Text>
|
|
||||||
<Text className="text-xs opacity-50">
|
|
||||||
{runtimeTicksToMinutes(item.RunTimeTicks)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ContextMenu.Trigger>
|
|
||||||
<ContextMenu.Content>
|
|
||||||
{contextMenuOptions.map((option) => (
|
|
||||||
<ContextMenu.Item
|
|
||||||
key={option.label}
|
|
||||||
onSelect={option.onSelect}
|
|
||||||
destructive={option.destructive}
|
|
||||||
>
|
|
||||||
<ContextMenu.ItemTitle style={{ color: "red" }}>
|
|
||||||
{option.label}
|
|
||||||
</ContextMenu.ItemTitle>
|
|
||||||
</ContextMenu.Item>
|
|
||||||
))}
|
|
||||||
</ContextMenu.Content>
|
|
||||||
</ContextMenu.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { EpisodeCard } from "./EpisodeCard";
|
|
||||||
import { Text } from "../common/Text";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { SeasonPicker } from "../series/SeasonPicker";
|
|
||||||
|
|
||||||
export const SeriesCard: React.FC<{ items: BaseItemDto[] }> = ({ items }) => {
|
|
||||||
const groupBySeason = useMemo(() => {
|
|
||||||
const seasons: Record<string, BaseItemDto[]> = {};
|
|
||||||
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (!seasons[item.SeasonName!]) {
|
|
||||||
seasons[item.SeasonName!] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
seasons[item.SeasonName!].push(item);
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(seasons).sort(
|
|
||||||
(a, b) => a[0].IndexNumber! - b[0].IndexNumber!
|
|
||||||
);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View className="flex flex-row items-center justify-between">
|
|
||||||
<Text className="text-2xl font-bold">{items[0].SeriesName}</Text>
|
|
||||||
<View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
|
|
||||||
<Text className="text-xs font-bold">{items.length}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text className="opacity-50 mb-2">TV-Series</Text>
|
|
||||||
{groupBySeason.map((seasonItems, seasonIndex) => (
|
|
||||||
<View key={seasonIndex}>
|
|
||||||
<Text className="mb-2 font-semibold">
|
|
||||||
{seasonItems[0].SeasonName}
|
|
||||||
</Text>
|
|
||||||
{seasonItems.map((item, index) => (
|
|
||||||
<View className="mb-2" key={index}>
|
|
||||||
<EpisodeCard item={item} />
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
|
||||||
import {
|
|
||||||
sortByAtom,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
sortOrderOptions,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
|
|
||||||
interface Props extends ViewProps {
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SortButton: React.FC<Props> = ({ title, ...props }) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useAtom(sortByAtom);
|
|
||||||
const [sortOrder, setSortOrder] = useAtom(sortOrderAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity>
|
|
||||||
<View
|
|
||||||
className={`
|
|
||||||
px-3 py-2 rounded-full flex flex-row items-center space-x-2 bg-neutral-900
|
|
||||||
`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Text>Sort by</Text>
|
|
||||||
<Ionicons
|
|
||||||
name="filter"
|
|
||||||
size={16}
|
|
||||||
color="white"
|
|
||||||
style={{ opacity: 0.5 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
{sortOptions?.map((g) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
value={sortBy.key === g.key ? "on" : "off"}
|
|
||||||
onValueChange={(next, previous) => {
|
|
||||||
if (next === "on") {
|
|
||||||
setSortBy(g);
|
|
||||||
} else {
|
|
||||||
setSortBy(sortOptions[0]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
key={g.key}
|
|
||||||
textValue={g.value}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenu.Separator />
|
|
||||||
<DropdownMenu.Group>
|
|
||||||
{sortOrderOptions.map((g) => (
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
value={sortOrder.key === g.key ? "on" : "off"}
|
|
||||||
onValueChange={(next, previous) => {
|
|
||||||
if (next === "on") {
|
|
||||||
setSortOrder(g);
|
|
||||||
} else {
|
|
||||||
setSortOrder(sortOrderOptions[0]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
key={g.key}
|
|
||||||
textValue={g.value}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemIndicator />
|
|
||||||
</DropdownMenu.CheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Group>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -12,13 +12,7 @@ import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
|
||||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import CastContext, {
|
|
||||||
PlayServicesState,
|
|
||||||
useCastDevice,
|
|
||||||
useRemoteMediaClient,
|
|
||||||
} from "react-native-google-cast";
|
|
||||||
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
|
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
|
||||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||||
import ios from "@/utils/profiles/ios";
|
import ios from "@/utils/profiles/ios";
|
||||||
@@ -41,40 +35,14 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const castDevice = useCastDevice();
|
|
||||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||||
const [, setPlaying] = useAtom(playingAtom);
|
const [, setPlaying] = useAtom(playingAtom);
|
||||||
|
|
||||||
const client = useRemoteMediaClient();
|
|
||||||
const { showActionSheetWithOptions } = useActionSheet();
|
const { showActionSheetWithOptions } = useActionSheet();
|
||||||
|
|
||||||
const openSelect = () => {
|
const openSelect = () => {
|
||||||
if (!castDevice?.deviceId) {
|
play("device");
|
||||||
play("device");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = ["Chromecast", "Device", "Cancel"];
|
|
||||||
const cancelButtonIndex = 2;
|
|
||||||
|
|
||||||
showActionSheetWithOptions(
|
|
||||||
{
|
|
||||||
options,
|
|
||||||
cancelButtonIndex,
|
|
||||||
},
|
|
||||||
(selectedIndex: number | undefined) => {
|
|
||||||
switch (selectedIndex) {
|
|
||||||
case 0:
|
|
||||||
play("cast");
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
play("device");
|
|
||||||
break;
|
|
||||||
case cancelButtonIndex:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const play = async (type: "device" | "cast") => {
|
const play = async (type: "device" | "cast") => {
|
||||||
@@ -93,37 +61,16 @@ export const SongsListItem: React.FC<Props> = ({
|
|||||||
item,
|
item,
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||||
sessionData,
|
sessionData,
|
||||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
deviceProfile: ios,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!url || !item) return;
|
if (!url || !item) return;
|
||||||
|
|
||||||
if (type === "cast" && client) {
|
setCp({
|
||||||
await CastContext.getPlayServicesState().then((state) => {
|
item,
|
||||||
if (state && state !== PlayServicesState.SUCCESS)
|
playbackUrl: url,
|
||||||
CastContext.showPlayServicesErrorDialog(state);
|
});
|
||||||
else {
|
setPlaying(true);
|
||||||
client.loadMedia({
|
|
||||||
mediaInfo: {
|
|
||||||
contentUrl: url,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
metadata: {
|
|
||||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
|
||||||
title: item.Name || "",
|
|
||||||
subtitle: item.Overview || "",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
startTime: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setCp({
|
|
||||||
item,
|
|
||||||
playbackUrl: url,
|
|
||||||
});
|
|
||||||
setPlaying(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useRouter } from "expo-router";
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||||
import { ItemCardText } from "../ItemCardText";
|
import { ItemCardText } from "../ItemCardText";
|
||||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||||
@@ -40,7 +39,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data.Items;
|
return response.data.Items;
|
||||||
@@ -51,7 +50,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
const selectedSeasonId: string | null = useMemo(
|
const selectedSeasonId: string | null = useMemo(
|
||||||
() =>
|
() =>
|
||||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||||
[seasons, seasonIndex],
|
[seasons, seasonIndex]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: episodes } = useQuery({
|
const { data: episodes } = useQuery({
|
||||||
@@ -70,7 +69,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data.Items as BaseItemDto[];
|
return response.data.Items as BaseItemDto[];
|
||||||
@@ -80,36 +79,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mb-2">
|
<View className="mb-2">
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<View className="flex flex-row px-4">
|
|
||||||
<TouchableOpacity className="bg-neutral-900 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>Season {seasonIndex}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Seasons</DropdownMenu.Label>
|
|
||||||
{seasons?.map((season: any) => (
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key={season.Name}
|
|
||||||
onSelect={() => {
|
|
||||||
setSeasonIndex(season.IndexNumber);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>{season.Name}</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
))}
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
{episodes && (
|
{episodes && (
|
||||||
<View className="mt-4">
|
<View className="mt-4">
|
||||||
<HorizontalScroll<BaseItemDto>
|
<HorizontalScroll<BaseItemDto>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
||||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
import { Loader } from "../Loader";
|
import { Loader } from "../Loader";
|
||||||
|
|
||||||
@@ -165,48 +164,6 @@ export const SettingToggles: React.FC = () => {
|
|||||||
supports.
|
supports.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger>
|
|
||||||
<TouchableOpacity className="bg-neutral-800 rounded-lg border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
|
||||||
<Text>{settings?.deviceProfile}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
loop={true}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
alignOffset={0}
|
|
||||||
avoidCollisions={true}
|
|
||||||
collisionPadding={8}
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<DropdownMenu.Label>Profiles</DropdownMenu.Label>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="1"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Expo" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Expo</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="2"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Native" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Native</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
|
||||||
key="3"
|
|
||||||
onSelect={() => {
|
|
||||||
updateSettings({ deviceProfile: "Old" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemTitle>Old</DropdownMenu.ItemTitle>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for downloading media using the Jellyfin API.
|
|
||||||
*
|
|
||||||
* @param api - The Jellyfin API instance
|
|
||||||
* @param userId - The user ID
|
|
||||||
* @returns An object with download-related functions and state
|
|
||||||
*/
|
|
||||||
export const useDownloadMedia = (api: Api | null, userId?: string | null) => {
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [_, setProgress] = useAtom(runningProcesses);
|
|
||||||
const downloadResumableRef = useRef<FileSystem.DownloadResumable | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloadMedia = useCallback(
|
|
||||||
async (item: BaseItemDto | null): Promise<boolean> => {
|
|
||||||
if (!item?.Id || !api || !userId) {
|
|
||||||
setError("Invalid item or API");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDownloading(true);
|
|
||||||
setError(null);
|
|
||||||
setProgress({ item, progress: 0 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filename = item.Id;
|
|
||||||
const fileUri = `${FileSystem.documentDirectory}${filename}`;
|
|
||||||
const url = `${api.basePath}/Items/${item.Id}/File`;
|
|
||||||
|
|
||||||
downloadResumableRef.current = FileSystem.createDownloadResumable(
|
|
||||||
url,
|
|
||||||
fileUri,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
(downloadProgress) => {
|
|
||||||
const currentProgress =
|
|
||||||
downloadProgress.totalBytesWritten /
|
|
||||||
downloadProgress.totalBytesExpectedToWrite;
|
|
||||||
setProgress({ item, progress: currentProgress * 100 });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await downloadResumableRef.current.downloadAsync();
|
|
||||||
|
|
||||||
if (!res?.uri) {
|
|
||||||
throw new Error("Download failed: No URI returned");
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateDownloadedFiles(item);
|
|
||||||
|
|
||||||
setIsDownloading(false);
|
|
||||||
setProgress(null);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error downloading media:", error);
|
|
||||||
setError("Failed to download media");
|
|
||||||
setIsDownloading(false);
|
|
||||||
setProgress(null);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[api, userId, setProgress],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelDownload = useCallback(async (): Promise<void> => {
|
|
||||||
if (!downloadResumableRef.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadResumableRef.current.pauseAsync();
|
|
||||||
setIsDownloading(false);
|
|
||||||
setError("Download cancelled");
|
|
||||||
setProgress(null);
|
|
||||||
downloadResumableRef.current = null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error cancelling download:", error);
|
|
||||||
setError("Failed to cancel download");
|
|
||||||
}
|
|
||||||
}, [setProgress]);
|
|
||||||
|
|
||||||
return { downloadMedia, isDownloading, error, cancelDownload };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the list of downloaded files in AsyncStorage.
|
|
||||||
*
|
|
||||||
* @param item - The item to add to the downloaded files list
|
|
||||||
*/
|
|
||||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
|
||||||
try {
|
|
||||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) ?? "[]",
|
|
||||||
);
|
|
||||||
const updatedFiles = [
|
|
||||||
...currentFiles.filter((file) => file.Id !== item.Id),
|
|
||||||
item,
|
|
||||||
];
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating downloaded files:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for managing downloaded files.
|
|
||||||
* @returns An object with functions to delete individual files and all files.
|
|
||||||
*/
|
|
||||||
export const useFiles = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes all downloaded files and clears the download record.
|
|
||||||
*/
|
|
||||||
const deleteAllFiles = async (): Promise<void> => {
|
|
||||||
const directoryUri = FileSystem.documentDirectory;
|
|
||||||
if (!directoryUri) {
|
|
||||||
console.error("Document directory is undefined");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileNames = await FileSystem.readDirectoryAsync(directoryUri);
|
|
||||||
await Promise.all(
|
|
||||||
fileNames.map((item) =>
|
|
||||||
FileSystem.deleteAsync(`${directoryUri}/${item}`, {
|
|
||||||
idempotent: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await AsyncStorage.removeItem("downloaded_files");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to delete all files:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a specific file and updates the download record.
|
|
||||||
* @param id - The ID of the file to delete.
|
|
||||||
*/
|
|
||||||
const deleteFile = async (id: string): Promise<void> => {
|
|
||||||
if (!id) {
|
|
||||||
console.error("Invalid file ID");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await FileSystem.deleteAsync(
|
|
||||||
`${FileSystem.documentDirectory}/${id}.mp4`,
|
|
||||||
{ idempotent: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentFiles = await getDownloadedFiles();
|
|
||||||
const updatedFiles = currentFiles.filter((f) => f.Id !== id);
|
|
||||||
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["downloaded_files"] });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to delete file with ID ${id}:`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { deleteFile, deleteAllFiles };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves the list of downloaded files from AsyncStorage.
|
|
||||||
* @returns An array of BaseItemDto objects representing downloaded files.
|
|
||||||
*/
|
|
||||||
async function getDownloadedFiles(): Promise<BaseItemDto[]> {
|
|
||||||
try {
|
|
||||||
const filesJson = await AsyncStorage.getItem("downloaded_files");
|
|
||||||
return filesJson ? JSON.parse(filesJson) : [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to retrieve downloaded files:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { runningProcesses } from "@/utils/atoms/downloads";
|
|
||||||
import { writeToLog } from "@/utils/log";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
|
||||||
*
|
|
||||||
* @param url - The URL of the HLS stream
|
|
||||||
* @param item - The BaseItemDto object representing the media item
|
|
||||||
* @returns An object with remuxing-related functions
|
|
||||||
*/
|
|
||||||
export const useRemuxHlsToMp4 = (url: string, item: BaseItemDto) => {
|
|
||||||
const [_, setProgress] = useAtom(runningProcesses);
|
|
||||||
|
|
||||||
if (!item.Id || !item.Name) {
|
|
||||||
writeToLog("ERROR", "useRemuxHlsToMp4 ~ missing arguments");
|
|
||||||
throw new Error("Item must have an Id and Name");
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = `${FileSystem.documentDirectory}${item.Id}.mp4`;
|
|
||||||
const command = `-y -loglevel quiet -thread_queue_size 512 -protocol_whitelist file,http,https,tcp,tls,crypto -multiple_requests 1 -tcp_nodelay 1 -fflags +genpts -i ${url} -c copy -bufsize 50M -max_muxing_queue_size 4096 ${output}`;
|
|
||||||
|
|
||||||
const startRemuxing = useCallback(async () => {
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ startRemuxing for item ${item.Name}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
setProgress({ item, progress: 0, startTime: new Date(), speed: 0 });
|
|
||||||
|
|
||||||
FFmpegKitConfig.enableStatisticsCallback((statistics) => {
|
|
||||||
const videoLength =
|
|
||||||
(item.MediaSources?.[0]?.RunTimeTicks || 0) / 10000000; // In seconds
|
|
||||||
const fps = item.MediaStreams?.[0]?.RealFrameRate || 25;
|
|
||||||
const totalFrames = videoLength * fps;
|
|
||||||
const processedFrames = statistics.getVideoFrameNumber();
|
|
||||||
const speed = statistics.getSpeed();
|
|
||||||
|
|
||||||
const percentage =
|
|
||||||
totalFrames > 0
|
|
||||||
? Math.floor((processedFrames / totalFrames) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
setProgress((prev) =>
|
|
||||||
prev?.item.Id === item.Id!
|
|
||||||
? { ...prev, progress: percentage, speed }
|
|
||||||
: prev,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Await the execution of the FFmpeg command and ensure that the callback is awaited properly.
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
FFmpegKit.executeAsync(command, async (session) => {
|
|
||||||
try {
|
|
||||||
const returnCode = await session.getReturnCode();
|
|
||||||
|
|
||||||
if (returnCode.isValueSuccess()) {
|
|
||||||
await updateDownloadedFiles(item);
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing completed successfully for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
} else if (returnCode.isValueError()) {
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
reject(new Error("Remuxing failed")); // Reject the promise on error
|
|
||||||
} else if (returnCode.isValueCancel()) {
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing was canceled for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(null);
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to remux:", error);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing failed for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
setProgress(null);
|
|
||||||
throw error; // Re-throw the error to propagate it to the caller
|
|
||||||
}
|
|
||||||
}, [output, item, command, setProgress]);
|
|
||||||
|
|
||||||
const cancelRemuxing = useCallback(() => {
|
|
||||||
FFmpegKit.cancel();
|
|
||||||
setProgress(null);
|
|
||||||
writeToLog(
|
|
||||||
"INFO",
|
|
||||||
`useRemuxHlsToMp4 ~ remuxing cancelled for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
}, [item.Name, setProgress]);
|
|
||||||
|
|
||||||
return { startRemuxing, cancelRemuxing };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the list of downloaded files in AsyncStorage.
|
|
||||||
*
|
|
||||||
* @param item - The item to add to the downloaded files list
|
|
||||||
*/
|
|
||||||
async function updateDownloadedFiles(item: BaseItemDto): Promise<void> {
|
|
||||||
try {
|
|
||||||
const currentFiles: BaseItemDto[] = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("downloaded_files")) || "[]",
|
|
||||||
);
|
|
||||||
const updatedFiles = [
|
|
||||||
...currentFiles.filter((i) => i.Id !== item.Id),
|
|
||||||
item,
|
|
||||||
];
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
"downloaded_files",
|
|
||||||
JSON.stringify(updatedFiles),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating downloaded files:", error);
|
|
||||||
writeToLog(
|
|
||||||
"ERROR",
|
|
||||||
`Failed to update downloaded files for item: ${item.Name}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
package.json
21
package.json
@@ -15,15 +15,14 @@
|
|||||||
"preset": "jest-expo"
|
"preset": "jest-expo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
|
||||||
"@expo/react-native-action-sheet": "^4.1.0",
|
"@expo/react-native-action-sheet": "^4.1.0",
|
||||||
"@expo/vector-icons": "^14.0.2",
|
"@expo/vector-icons": "^14.0.2",
|
||||||
"@gorhom/bottom-sheet": "^4",
|
"@gorhom/bottom-sheet": "^4",
|
||||||
"@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",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
|
||||||
"@react-native-community/netinfo": "11.3.1",
|
"@react-native-community/netinfo": "11.3.1",
|
||||||
"@react-native-menu/menu": "^1.1.2",
|
"@react-native-menu/menu": "^1.1.2",
|
||||||
|
"@react-native-tvos/config-tv": "^0.0.10",
|
||||||
"@react-navigation/native": "^6.0.2",
|
"@react-navigation/native": "^6.0.2",
|
||||||
"@shopify/flash-list": "1.6.4",
|
"@shopify/flash-list": "1.6.4",
|
||||||
"@tanstack/react-query": "^5.51.16",
|
"@tanstack/react-query": "^5.51.16",
|
||||||
@@ -37,33 +36,27 @@
|
|||||||
"expo-dev-client": "~4.0.23",
|
"expo-dev-client": "~4.0.23",
|
||||||
"expo-device": "~6.0.2",
|
"expo-device": "~6.0.2",
|
||||||
"expo-font": "~12.0.9",
|
"expo-font": "~12.0.9",
|
||||||
"expo-haptics": "~13.0.1",
|
|
||||||
"expo-image": "~1.12.13",
|
"expo-image": "~1.12.13",
|
||||||
"expo-keep-awake": "~13.0.2",
|
"expo-keep-awake": "~13.0.2",
|
||||||
"expo-linking": "~6.3.1",
|
"expo-linking": "~6.3.1",
|
||||||
"expo-navigation-bar": "~3.0.7",
|
"expo-navigation-bar": "~3.0.7",
|
||||||
"expo-router": "~3.5.23",
|
"expo-router": "~3.5.23",
|
||||||
"expo-screen-orientation": "~7.0.5",
|
|
||||||
"expo-sensors": "~13.0.9",
|
|
||||||
"expo-splash-screen": "~0.27.5",
|
"expo-splash-screen": "~0.27.5",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"expo-system-ui": "~3.0.7",
|
"expo-system-ui": "~3.0.7",
|
||||||
"expo-updates": "~0.25.22",
|
"expo-updates": "~0.25.22",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"ffmpeg-kit-react-native": "^6.0.2",
|
|
||||||
"jotai": "^2.9.1",
|
"jotai": "^2.9.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nativewind": "^2.0.11",
|
"nativewind": "^2.0.11",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "npm:react-native-tvos@latest",
|
||||||
"react-native-circular-progress": "^1.4.0",
|
"react-native-circular-progress": "^1.4.0",
|
||||||
"react-native-compressor": "^1.8.25",
|
"react-native-compressor": "^1.8.25",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
"react-native-google-cast": "^4.8.2",
|
"react-native-ios-utilities": "^4.5.0",
|
||||||
"react-native-ios-context-menu": "^2.5.1",
|
|
||||||
"react-native-ios-utilities": "^4.4.5",
|
|
||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-reanimated": "~3.10.1",
|
||||||
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
||||||
"react-native-safe-area-context": "4.10.5",
|
"react-native-safe-area-context": "4.10.5",
|
||||||
@@ -76,7 +69,6 @@
|
|||||||
"tailwindcss": "3.3.2",
|
"tailwindcss": "3.3.2",
|
||||||
"use-debounce": "^10.0.3",
|
"use-debounce": "^10.0.3",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"zeego": "^1.10.0",
|
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -89,5 +81,12 @@
|
|||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"typescript": "~5.3.3"
|
"typescript": "~5.3.3"
|
||||||
},
|
},
|
||||||
|
"expo": {
|
||||||
|
"install": {
|
||||||
|
"exclude": [
|
||||||
|
"react-native"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { isLoaded } from "expo-font";
|
|
||||||
import { router, useSegments } from "expo-router";
|
import { router, useSegments } from "expo-router";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -31,15 +29,14 @@ interface JellyfinContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||||
undefined,
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const getOrSetDeviceId = async () => {
|
const getOrSetDeviceId = async () => {
|
||||||
let deviceId = await AsyncStorage.getItem("deviceId");
|
let deviceId = null;
|
||||||
|
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
deviceId = uuid.v4() as string;
|
deviceId = uuid.v4() as string;
|
||||||
await AsyncStorage.setItem("deviceId", deviceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return deviceId;
|
return deviceId;
|
||||||
@@ -58,7 +55,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
new Jellyfin({
|
new Jellyfin({
|
||||||
clientInfo: { name: "Streamyfin", version: "0.6.1" },
|
clientInfo: { name: "Streamyfin", version: "0.6.1" },
|
||||||
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
deviceInfo: { name: Platform.OS === "ios" ? "iOS" : "Android", id },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -67,8 +64,9 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const [user, setUser] = useAtom(userAtom);
|
const [user, setUser] = useAtom(userAtom);
|
||||||
|
|
||||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||||
const servers =
|
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||||
await jellyfin?.discovery.getRecommendedServerCandidates(url);
|
url
|
||||||
|
);
|
||||||
return servers?.map((server) => ({ address: server.address })) || [];
|
return servers?.map((server) => ({ address: server.address })) || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,7 +77,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
||||||
|
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
await AsyncStorage.setItem("serverUrl", server.address);
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Failed to set server:", error);
|
console.error("Failed to set server:", error);
|
||||||
@@ -88,7 +85,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const removeServerMutation = useMutation({
|
const removeServerMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await AsyncStorage.removeItem("serverUrl");
|
|
||||||
setApi(null);
|
setApi(null);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -110,9 +106,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
if (auth.data.AccessToken && auth.data.User) {
|
if (auth.data.AccessToken && auth.data.User) {
|
||||||
setUser(auth.data.User);
|
setUser(auth.data.User);
|
||||||
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
|
|
||||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||||
await AsyncStorage.setItem("token", auth.data?.AccessToken);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Invalid username or password");
|
throw new Error("Invalid username or password");
|
||||||
}
|
}
|
||||||
@@ -124,7 +118,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
await AsyncStorage.removeItem("token");
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -132,36 +125,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isLoading, isFetching } = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"initializeJellyfin",
|
|
||||||
user?.Id,
|
|
||||||
api?.basePath,
|
|
||||||
jellyfin?.clientInfo,
|
|
||||||
],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const token = await AsyncStorage.getItem("token");
|
|
||||||
const serverUrl = await AsyncStorage.getItem("serverUrl");
|
|
||||||
const user = JSON.parse(
|
|
||||||
(await AsyncStorage.getItem("user")) as string,
|
|
||||||
) as UserDto;
|
|
||||||
|
|
||||||
if (serverUrl && token && user.Id && jellyfin) {
|
|
||||||
const apiInstance = jellyfin.createApi(serverUrl, token);
|
|
||||||
setApi(apiInstance);
|
|
||||||
setUser(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
enabled: !user?.Id || !api || !jellyfin,
|
|
||||||
});
|
|
||||||
|
|
||||||
const contextValue: JellyfinContextValue = {
|
const contextValue: JellyfinContextValue = {
|
||||||
discoverServers,
|
discoverServers,
|
||||||
setServer: (server) => setServerMutation.mutateAsync(server),
|
setServer: (server) => setServerMutation.mutateAsync(server),
|
||||||
@@ -171,7 +134,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
logout: () => logoutMutation.mutateAsync(),
|
logout: () => logoutMutation.mutateAsync(),
|
||||||
};
|
};
|
||||||
|
|
||||||
useProtectedRoute(user, isLoading || isFetching);
|
useProtectedRoute(user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JellyfinContext.Provider value={contextValue}>
|
<JellyfinContext.Provider value={contextValue}>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import React, { createContext } from "react";
|
|
||||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
|
||||||
|
|
||||||
const JobQueueContext = createContext(null);
|
|
||||||
|
|
||||||
export const JobQueueProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
useJobProcessor();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<JobQueueContext.Provider value={null}>{children}</JobQueueContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
type Settings = {
|
type Settings = {
|
||||||
autoRotate?: boolean;
|
autoRotate?: boolean;
|
||||||
@@ -12,55 +10,27 @@ type Settings = {
|
|||||||
mediaListCollectionIds?: string[];
|
mediaListCollectionIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// Default settings
|
||||||
*
|
const defaultSettings: Settings = {
|
||||||
* The settings atom is a Jotai atom that stores the user's settings.
|
autoRotate: true,
|
||||||
* It is initialized with a default value of null, which indicates that the settings have not been loaded yet.
|
forceLandscapeInVideoPlayer: false,
|
||||||
* The settings are loaded from AsyncStorage when the atom is read for the first time.
|
openFullScreenVideoPlayerByDefault: true,
|
||||||
*
|
usePopularPlugin: false,
|
||||||
*/
|
deviceProfile: "Expo",
|
||||||
|
forceDirectPlay: false,
|
||||||
// Utility function to load settings from AsyncStorage
|
mediaListCollectionIds: [],
|
||||||
const loadSettings = async (): Promise<Settings> => {
|
|
||||||
const jsonValue = await AsyncStorage.getItem("settings");
|
|
||||||
return jsonValue != null
|
|
||||||
? JSON.parse(jsonValue)
|
|
||||||
: {
|
|
||||||
autoRotate: true,
|
|
||||||
forceLandscapeInVideoPlayer: false,
|
|
||||||
openFullScreenVideoPlayerByDefault: false,
|
|
||||||
usePopularPlugin: false,
|
|
||||||
deviceProfile: "Expo",
|
|
||||||
forceDirectPlay: false,
|
|
||||||
mediaListCollectionIds: [],
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Utility function to save settings to AsyncStorage
|
// Create an atom to store the settings in memory, initialized with default settings
|
||||||
const saveSettings = async (settings: Settings) => {
|
const settingsAtom = atom<Settings>(defaultSettings);
|
||||||
const jsonValue = JSON.stringify(settings);
|
|
||||||
await AsyncStorage.setItem("settings", jsonValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create an atom to store the settings in memory
|
// A hook to manage settings, providing a way to update them
|
||||||
const settingsAtom = atom<Settings | null>(null);
|
|
||||||
|
|
||||||
// A hook to manage settings, loading them on initial mount and providing a way to update them
|
|
||||||
export const useSettings = () => {
|
export const useSettings = () => {
|
||||||
const [settings, setSettings] = useAtom(settingsAtom);
|
const [settings, setSettings] = useAtom(settingsAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
const updateSettings = (update: Partial<Settings>) => {
|
||||||
if (settings === null) {
|
const newSettings = { ...settings, ...update };
|
||||||
loadSettings().then(setSettings);
|
setSettings(newSettings);
|
||||||
}
|
|
||||||
}, [settings, setSettings]);
|
|
||||||
|
|
||||||
const updateSettings = async (update: Partial<Settings>) => {
|
|
||||||
if (settings) {
|
|
||||||
const newSettings = { ...settings, ...update };
|
|
||||||
setSettings(newSettings);
|
|
||||||
await saveSettings(newSettings);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return [settings, updateSettings] as const;
|
return [settings, updateSettings] as const;
|
||||||
|
|||||||
18
utils/log.ts
18
utils/log.ts
@@ -1,4 +1,4 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import { atom } from "jotai";
|
||||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||||
|
|
||||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||||
@@ -10,8 +10,7 @@ interface LogEntry {
|
|||||||
data?: any;
|
data?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const asyncStorage = createJSONStorage(() => AsyncStorage);
|
const logsAtom = atom([]);
|
||||||
const logsAtom = atomWithStorage("logs", [], asyncStorage);
|
|
||||||
|
|
||||||
export const writeToLog = async (
|
export const writeToLog = async (
|
||||||
level: LogLevel,
|
level: LogLevel,
|
||||||
@@ -25,23 +24,16 @@ export const writeToLog = async (
|
|||||||
data: data,
|
data: data,
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentLogs = await AsyncStorage.getItem("logs");
|
const logs: LogEntry[] = [];
|
||||||
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
|
|
||||||
logs.push(newEntry);
|
logs.push(newEntry);
|
||||||
|
|
||||||
const maxLogs = 100;
|
const maxLogs = 100;
|
||||||
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
|
|
||||||
|
|
||||||
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const readFromLog = async (): Promise<LogEntry[]> => {
|
export const readFromLog = async (): Promise<LogEntry[]> => {
|
||||||
const logs = await AsyncStorage.getItem("logs");
|
return [];
|
||||||
return logs ? JSON.parse(logs) : [];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearLogs = async () => {
|
export const clearLogs = async () => {};
|
||||||
await AsyncStorage.removeItem("logs");
|
|
||||||
};
|
|
||||||
|
|
||||||
export default logsAtom;
|
export default logsAtom;
|
||||||
|
|||||||
Reference in New Issue
Block a user