mirror of
https://github.com/streamyfin/streamyfin.git
synced 2025-08-20 18:37:18 +02:00
Compare commits
9 Commits
develop
...
feat/tv-os
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7fd382f2 | ||
|
|
d8201aa1fc | ||
|
|
dec45056f3 | ||
|
|
1d41b7080f | ||
|
|
83a09ad74a | ||
|
|
65ac147441 | ||
|
|
6a070cfbe0 | ||
|
|
9d1a03a5f2 | ||
|
|
08b28f7599 |
@@ -108,7 +108,6 @@ If you have questions or need support, feel free to reach out:
|
|||||||
|
|
||||||
- GitHub Issues: Report bugs or request features here.
|
- GitHub Issues: Report bugs or request features here.
|
||||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||||
-
|
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
|
|||||||
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}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
package.json
25
package.json
@@ -15,55 +15,48 @@
|
|||||||
"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",
|
||||||
"@types/lodash": "^4.17.7",
|
"@types/lodash": "^4.17.7",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"axios": "^1.7.3",
|
"axios": "^1.7.3",
|
||||||
"expo": "~51.0.27",
|
"expo": "~51.0.28",
|
||||||
"expo-blur": "~13.0.2",
|
"expo-blur": "~13.0.2",
|
||||||
"expo-build-properties": "~0.12.5",
|
"expo-build-properties": "~0.12.5",
|
||||||
"expo-constants": "~16.0.2",
|
"expo-constants": "~16.0.2",
|
||||||
"expo-dev-client": "~4.0.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.21",
|
"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