forked from Ninjalama/streamyfin_mirror
Compare commits
9 Commits
feat/i18n
...
feat/tv-os
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca7fd382f2 | ||
|
|
d8201aa1fc | ||
|
|
dec45056f3 | ||
|
|
1d41b7080f | ||
|
|
83a09ad74a | ||
|
|
65ac147441 | ||
|
|
6a070cfbe0 | ||
|
|
9d1a03a5f2 | ||
|
|
08b28f7599 |
@@ -26,10 +26,6 @@ Streamyfin includes some exciting experimental features like media downloading a
|
||||
|
||||
Downloading works by using ffmpeg to convert a HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||
|
||||
## Roadmap for V1
|
||||
|
||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
||||
|
||||
## Get it now
|
||||
|
||||
<div style="display:flex;">
|
||||
|
||||
42
app.json
42
app.json
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"android": {
|
||||
"jsEngine": "hermes",
|
||||
"versionCode": 18,
|
||||
"versionCode": 17,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/icon.png"
|
||||
},
|
||||
@@ -46,15 +46,9 @@
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"@react-native-tvos/config-tv",
|
||||
"expo-router",
|
||||
"expo-font",
|
||||
"@config-plugins/ffmpeg-kit-react-native",
|
||||
[
|
||||
"react-native-google-cast",
|
||||
{
|
||||
"useDefaultExpandedMediaControls": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"react-native-video",
|
||||
{
|
||||
@@ -67,37 +61,7 @@
|
||||
"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."
|
||||
}
|
||||
],
|
||||
"expo-localization"
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
|
||||
@@ -4,12 +4,9 @@ import { BlurView } from "expo-blur";
|
||||
import * as NavigationBar from "expo-navigation-bar";
|
||||
import { Tabs } from "expo-router";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, StyleSheet } from "react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS === "android") {
|
||||
NavigationBar.setBackgroundColorAsync("#121212");
|
||||
@@ -53,7 +50,7 @@ export default function TabLayout() {
|
||||
name="home"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: t("tabs.home"),
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon
|
||||
name={focused ? "home" : "home-outline"}
|
||||
@@ -66,7 +63,7 @@ export default function TabLayout() {
|
||||
name="search"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: t("tabs.search"),
|
||||
title: "Search",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon name={focused ? "search" : "search"} color={color} />
|
||||
),
|
||||
@@ -76,7 +73,7 @@ export default function TabLayout() {
|
||||
name="library"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: t("tabs.library"),
|
||||
title: "Library",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<TabBarIcon
|
||||
name={focused ? "apps" : "apps-outline"}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { TouchableOpacity } from "react-native";
|
||||
|
||||
export default function IndexLayout() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
@@ -15,25 +12,12 @@ export default function IndexLayout() {
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerLargeTitle: true,
|
||||
headerTitle: t("home.home"),
|
||||
headerTitle: "Home",
|
||||
headerBlurEffect: "prominent",
|
||||
headerTransparent: Platform.OS === "ios" ? true : 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: () => (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
<Chromecast />
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push("/(auth)/settings");
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionLi
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import {
|
||||
@@ -20,20 +19,16 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshControl, ScrollView, View } from "react-native";
|
||||
|
||||
export default function index() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [settings, _] = useSettings();
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
||||
|
||||
@@ -174,11 +169,7 @@ export default function index() {
|
||||
});
|
||||
|
||||
const { data: mediaListCollections } = useQuery({
|
||||
queryKey: [
|
||||
"mediaListCollections-home",
|
||||
user?.Id,
|
||||
settings?.mediaListCollectionIds,
|
||||
],
|
||||
queryKey: ["mediaListCollections-home", user?.Id],
|
||||
queryFn: async () => {
|
||||
if (!api || !user?.Id) return [];
|
||||
|
||||
@@ -190,16 +181,9 @@ export default function index() {
|
||||
includeItemTypes: ["BoxSet"],
|
||||
});
|
||||
|
||||
const ids =
|
||||
response.data.Items?.filter(
|
||||
(c) =>
|
||||
c.Name !== "cf_carousel" &&
|
||||
settings?.mediaListCollectionIds?.includes(c.Id!)
|
||||
) ?? [];
|
||||
|
||||
return ids;
|
||||
return [];
|
||||
},
|
||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
||||
enabled: !!api && !!user?.Id && false,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
@@ -219,22 +203,7 @@ export default function index() {
|
||||
if (isConnected === false) {
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6 px-8">
|
||||
<Text className="text-3xl font-bold mb-2">{t("home.noInternet")}</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
{t("home.noInternetMessage")}
|
||||
</Text>
|
||||
<View className="mt-4">
|
||||
<Button
|
||||
color="purple"
|
||||
onPress={() => router.push("/(auth)/downloads")}
|
||||
justify="center"
|
||||
iconRight={
|
||||
<Ionicons name="arrow-forward" size={20} color="white" />
|
||||
}
|
||||
>
|
||||
{t("home.goToDownloads")}
|
||||
</Button>
|
||||
</View>
|
||||
<Text className="text-3xl font-bold mb-2">No Internet</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -242,8 +211,10 @@ export default function index() {
|
||||
if (isError)
|
||||
return (
|
||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
||||
<Text className="text-3xl font-bold mb-2">{t("home.oops")}</Text>
|
||||
<Text className="text-center opacity-70">{t("home.errorMessage")}</Text>
|
||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
||||
<Text className="text-center opacity-70">
|
||||
Something went wrong.{"\n"}Please log out and in again.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -266,37 +237,33 @@ export default function index() {
|
||||
<LargeMovieCarousel />
|
||||
|
||||
<ScrollingCollectionList
|
||||
title={t("home.continueWatching")}
|
||||
title="Continue Watching"
|
||||
data={data}
|
||||
loading={isLoading}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title={t("home.nextUp")}
|
||||
title="Next Up"
|
||||
data={nextUpData}
|
||||
loading={isLoadingNextUp}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
|
||||
{mediaListCollections?.map((ml) => (
|
||||
<MediaListSection key={ml.Id} collection={ml} />
|
||||
))}
|
||||
|
||||
<ScrollingCollectionList
|
||||
title={t("home.recentlyAddedMovies")}
|
||||
title="Recently Added in Movies"
|
||||
data={recentlyAddedInMovies}
|
||||
loading={isLoadingRecentlyAddedMovies}
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title={t("home.recentlyAddedTVShows")}
|
||||
title="Recently Added in TV-Shows"
|
||||
data={recentlyAddedInTVShows}
|
||||
loading={isLoadingRecentlyAddedTVShows}
|
||||
/>
|
||||
|
||||
<ScrollingCollectionList
|
||||
title={t("home.suggestions")}
|
||||
title="Suggestions"
|
||||
data={suggestions}
|
||||
loading={isLoadingSuggestions}
|
||||
orientation="horizontal"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { SongsList } from "@/components/music/SongsList";
|
||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
||||
@@ -24,16 +23,6 @@ export default function page() {
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View className="">
|
||||
<Chromecast />
|
||||
</View>
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const { data: album } = useQuery({
|
||||
queryKey: ["album", albumId, artistId],
|
||||
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,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { OverviewText } from "@/components/OverviewText";
|
||||
import { PlayButton } from "@/components/PlayButton";
|
||||
@@ -25,7 +24,6 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import native from "@/utils/profiles/native";
|
||||
import old from "@/utils/profiles/old";
|
||||
@@ -36,11 +34,6 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { ParallaxScrollView } from "../../../components/ParallaxPage";
|
||||
|
||||
const page: React.FC = () => {
|
||||
@@ -52,14 +45,10 @@ const page: React.FC = () => {
|
||||
|
||||
const [settings] = useSettings();
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
|
||||
const [, setCurrentlyPlying] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
const [, setFullscreen] = useAtom(fullScreenAtom);
|
||||
|
||||
const client = useRemoteMediaClient();
|
||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
@@ -100,7 +89,6 @@ const page: React.FC = () => {
|
||||
"playbackUrl",
|
||||
item?.Id,
|
||||
maxBitrate,
|
||||
castDevice,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
settings,
|
||||
@@ -110,9 +98,7 @@ const page: React.FC = () => {
|
||||
|
||||
let deviceProfile: any = ios;
|
||||
|
||||
if (castDevice?.deviceId) {
|
||||
deviceProfile = chromecastProfile;
|
||||
} else if (settings?.deviceProfile === "Native") {
|
||||
if (settings?.deviceProfile === "Native") {
|
||||
deviceProfile = native;
|
||||
} else if (settings?.deviceProfile === "Old") {
|
||||
deviceProfile = old;
|
||||
@@ -143,34 +129,13 @@ const page: React.FC = () => {
|
||||
async (type: "device" | "cast" = "device") => {
|
||||
if (!playbackUrl || !item) return;
|
||||
|
||||
if (type === "cast" && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: playbackUrl,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCurrentlyPlying({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||
setFullscreen(true);
|
||||
}
|
||||
setCurrentlyPlying({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
if (settings?.openFullScreenVideoPlayerByDefault === true) {
|
||||
setFullscreen(true);
|
||||
}
|
||||
},
|
||||
[playbackUrl, item, settings]
|
||||
@@ -245,11 +210,6 @@ const page: React.FC = () => {
|
||||
</View>
|
||||
|
||||
<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} />
|
||||
</View>
|
||||
|
||||
@@ -276,7 +236,7 @@ const page: React.FC = () => {
|
||||
<NextEpisodeButton item={item} type="previous" className="mr-2" />
|
||||
<PlayButton
|
||||
item={item}
|
||||
chromecastReady={chromecastReady}
|
||||
chromecastReady={false}
|
||||
onPress={onPressPlay}
|
||||
className="grow"
|
||||
/>
|
||||
|
||||
@@ -6,13 +6,9 @@ import { clearLogs, readFromLog } from "@/utils/log";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
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() {
|
||||
const { logout } = useJellyfin();
|
||||
const { deleteAllFiles } = useFiles();
|
||||
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -33,30 +29,14 @@ export default function settings() {
|
||||
<ListItem title="Server" subTitle={api?.basePath} />
|
||||
</View>
|
||||
|
||||
<SettingToggles />
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Button color="black" onPress={logout}>
|
||||
Log out
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await deleteAllFiles();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all downloaded files
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onPress={async () => {
|
||||
await clearLogs();
|
||||
Haptics.notificationAsync(
|
||||
Haptics.NotificationFeedbackType.Success,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete all logs
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { AudioTrackSelector } from "@/components/AudioTrackSelector";
|
||||
import { Bitrate, BitrateSelector } from "@/components/BitrateSelector";
|
||||
import { Chromecast } from "@/components/Chromecast";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import {
|
||||
currentlyPlayingItemAtom,
|
||||
playingAtom,
|
||||
} from "@/components/CurrentlyPlayingBar";
|
||||
import { DownloadItem } from "@/components/DownloadItem";
|
||||
import { Loader } from "@/components/Loader";
|
||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||
@@ -19,20 +17,14 @@ import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Image } from "expo-image";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
|
||||
const page: React.FC = () => {
|
||||
const local = useLocalSearchParams();
|
||||
@@ -43,20 +35,8 @@ const page: React.FC = () => {
|
||||
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
|
||||
const castDevice = useCastDevice();
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View className="">
|
||||
<Chromecast />
|
||||
</View>
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const chromecastReady = useMemo(() => !!castDevice?.deviceId, [castDevice]);
|
||||
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
|
||||
const [selectedSubtitleStream, setSelectedSubtitleStream] =
|
||||
useState<number>(0);
|
||||
@@ -113,7 +93,6 @@ const page: React.FC = () => {
|
||||
"playbackUrl",
|
||||
item?.Id,
|
||||
maxBitrate,
|
||||
castDevice,
|
||||
selectedAudioStream,
|
||||
selectedSubtitleStream,
|
||||
],
|
||||
@@ -127,7 +106,7 @@ const page: React.FC = () => {
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
maxStreamingBitrate: maxBitrate.value,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
||||
deviceProfile: ios,
|
||||
audioStreamIndex: selectedAudioStream,
|
||||
subtitleStreamIndex: selectedSubtitleStream,
|
||||
});
|
||||
@@ -141,38 +120,16 @@ const page: React.FC = () => {
|
||||
});
|
||||
|
||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const client = useRemoteMediaClient();
|
||||
|
||||
const onPressPlay = useCallback(
|
||||
async (type: "device" | "cast" = "device") => {
|
||||
if (!playbackUrl || !item) return;
|
||||
|
||||
if (type === "cast" && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
client.loadMedia({
|
||||
mediaInfo: {
|
||||
contentUrl: playbackUrl,
|
||||
contentType: "video/mp4",
|
||||
metadata: {
|
||||
type: item.Type === "Episode" ? "tvShow" : "movie",
|
||||
title: item.Name || "",
|
||||
subtitle: item.Overview || "",
|
||||
},
|
||||
},
|
||||
startTime: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
}
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl,
|
||||
});
|
||||
setPlaying(true);
|
||||
},
|
||||
[playbackUrl, item]
|
||||
);
|
||||
@@ -221,14 +178,6 @@ const page: React.FC = () => {
|
||||
<MoviesTitleHeader item={item} />
|
||||
<Text className="text-center opacity-50">{item?.ProductionYear}</Text>
|
||||
</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 className="flex flex-col p-4 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" />
|
||||
<PlayButton
|
||||
item={item}
|
||||
chromecastReady={chromecastReady}
|
||||
chromecastReady={false}
|
||||
onPress={onPressPlay}
|
||||
className="grow"
|
||||
/>
|
||||
|
||||
236
app/_layout.tsx
236
app/_layout.tsx
@@ -1,25 +1,19 @@
|
||||
import { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useFonts } from "expo-font";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { Stack } from "expo-router";
|
||||
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 { CurrentlyPlayingBar } from "@/components/CurrentlyPlayingBar";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { useJobProcessor } from "@/utils/atoms/queue";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { I18nextProvider, useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n";
|
||||
import { getLocales } from "expo-localization";
|
||||
import "react-native-reanimated";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -45,20 +39,14 @@ export default function RootLayout() {
|
||||
|
||||
return (
|
||||
<JotaiProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Layout />
|
||||
</I18nextProvider>
|
||||
<Layout />
|
||||
</JotaiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
const [settings, updateSettings] = useSettings();
|
||||
|
||||
useKeepAwake();
|
||||
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const queryClientRef = useRef<QueryClient>(
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -73,125 +61,99 @@ function Layout() {
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.autoRotate === true)
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
|
||||
else
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(
|
||||
settings?.preferedLanguage || getLocales()[0].languageCode || "en"
|
||||
);
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<JobQueueProvider>
|
||||
<ActionSheetProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<JellyfinProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/settings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/downloads"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Downloads",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/[artistId]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/albums/[albumId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/songs/[songId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/series/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</JellyfinProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ActionSheetProvider>
|
||||
</JobQueueProvider>
|
||||
<ActionSheetProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<JellyfinProvider>
|
||||
<StatusBar style="light" backgroundColor="#000" />
|
||||
<ThemeProvider value={DarkTheme}>
|
||||
<Stack initialRouteName="/home">
|
||||
<Stack.Screen
|
||||
name="(auth)/(tabs)"
|
||||
options={{
|
||||
headerShown: false,
|
||||
title: "",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/settings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: "Settings",
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/items/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/collections/[collectionId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/artists/[artistId]/page"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/albums/[albumId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: true,
|
||||
headerStyle: { backgroundColor: "black" },
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/songs/[songId]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(auth)/series/[id]"
|
||||
options={{
|
||||
title: "",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{ headerShown: false, title: "Login" }}
|
||||
/>
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<CurrentlyPlayingBar />
|
||||
</ThemeProvider>
|
||||
</JellyfinProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</ActionSheetProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Button } from "@/components/Button";
|
||||
import { Input } from "@/components/common/Input";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { AxiosError } from "axios";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
@@ -23,7 +21,6 @@ const CredentialsSchema = z.object({
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { setServer, login, removeServer } = useJellyfin();
|
||||
const [api] = useAtom(apiAtom);
|
||||
|
||||
@@ -75,7 +72,7 @@ const Login: React.FC = () => {
|
||||
<View className="mb-4">
|
||||
<Text className="text-3xl font-bold mb-2">Streamyfin</Text>
|
||||
<Text className="text-neutral-500 mb-2">
|
||||
{t("server.server_label", { serverURL: api.basePath })}
|
||||
Server: {api.basePath}
|
||||
</Text>
|
||||
<Button
|
||||
color="black"
|
||||
@@ -92,17 +89,17 @@ const Login: React.FC = () => {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("server.change_server")}
|
||||
Change server
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col space-y-2">
|
||||
<Text className="text-2xl font-bold">{t("login.login")}</Text>
|
||||
<Text className="text-2xl font-bold">Log in</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{t("login.login_subtitle")}
|
||||
Log in to any user account
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t("login.username_placeholder")}
|
||||
placeholder="Username"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, username: text })
|
||||
}
|
||||
@@ -119,7 +116,7 @@ const Login: React.FC = () => {
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
placeholder={t("login.password_placeholder")}
|
||||
placeholder="Password"
|
||||
onChangeText={(text) =>
|
||||
setCredentials({ ...credentials, password: text })
|
||||
}
|
||||
@@ -142,7 +139,7 @@ const Login: React.FC = () => {
|
||||
loading={loading}
|
||||
className="mt-auto mb-2"
|
||||
>
|
||||
{t("login.login_button")}
|
||||
Log in
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
@@ -161,10 +158,10 @@ const Login: React.FC = () => {
|
||||
<View className="flex flex-col gap-y-2">
|
||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
||||
<Text className="text-neutral-500">
|
||||
{t("server.connect_to_server")}
|
||||
Connect to your Jellyfin server
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t("server.server_url_placeholder")}
|
||||
placeholder="Server URL"
|
||||
onChangeText={setServerURL}
|
||||
value={serverURL}
|
||||
keyboardType="url"
|
||||
@@ -173,11 +170,12 @@ const Login: React.FC = () => {
|
||||
textContentType="URL"
|
||||
maxLength={500}
|
||||
/>
|
||||
<Text className="opacity-30">{t("server.server_url_hint")}</Text>
|
||||
<LanguageSwitcher />
|
||||
<Text className="opacity-30">
|
||||
Server URL requires http or https
|
||||
</Text>
|
||||
</View>
|
||||
<Button onPress={() => handleConnect(serverURL)} className="mb-2">
|
||||
{t("server.connect_button")}
|
||||
Connect
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
@@ -22,12 +21,12 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
const audioStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter((x) => x.Type === "Audio"),
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const selectedAudioSteam = useMemo(
|
||||
() => audioStreams?.find((x) => x.Index === selected),
|
||||
[audioStreams, selected],
|
||||
[audioStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,45 +35,9 @@ export const AudioTrackSelector: React.FC<Props> = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Audio streams</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{tc(selectedAudioSteam?.DisplayTitle, 13)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Audio streams</DropdownMenu.Label>
|
||||
{audioStreams?.map((audio, idx: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={idx.toString()}
|
||||
onSelect={() => {
|
||||
if (audio.Index !== null && audio.Index !== undefined)
|
||||
onChange(audio.Index);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>
|
||||
{audio.DisplayTitle}
|
||||
</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View
|
||||
className="flex flex-row items-center justify-between"
|
||||
{...props}
|
||||
></View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
|
||||
@@ -46,42 +45,9 @@ export const BitrateSelector: React.FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Bitrate</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text>
|
||||
{BITRATES.find((b) => b.value === selected.value)?.key}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
loop={true}
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
avoidCollisions={true}
|
||||
collisionPadding={8}
|
||||
sideOffset={8}
|
||||
>
|
||||
<DropdownMenu.Label>Bitrates</DropdownMenu.Label>
|
||||
{BITRATES?.map((b, index: number) => (
|
||||
<DropdownMenu.Item
|
||||
key={index.toString()}
|
||||
onSelect={() => {
|
||||
onChange(b);
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemTitle>{b.key}</DropdownMenu.ItemTitle>
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</View>
|
||||
<View
|
||||
className="flex flex-row items-center justify-between"
|
||||
{...props}
|
||||
></View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Haptics from "expo-haptics";
|
||||
import React, { PropsWithChildren, ReactNode, useMemo } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Loader } from "./Loader";
|
||||
@@ -51,7 +50,6 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
|
||||
onPress={() => {
|
||||
if (!loading && !disabled && onPress) {
|
||||
onPress();
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { getLocales } from "expo-localization";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View, ViewProps } from "react-native";
|
||||
|
||||
interface Props extends ViewProps {}
|
||||
|
||||
export const LanguageSwitcher: React.FC<Props> = ({ ...props }) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const lngs = ["en", "sv"];
|
||||
|
||||
const [settings, updateSettings] = useSettings();
|
||||
return (
|
||||
<View className="flex flex-row space-x-2" {...props}>
|
||||
{lngs.map((l) => (
|
||||
<TouchableOpacity
|
||||
key={l}
|
||||
onPress={() => {
|
||||
i18n.changeLanguage(l);
|
||||
updateSettings({ preferedLanguage: l });
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={`uppercase ${
|
||||
i18n.language === l ? "text-blue-500" : "text-gray-400 underline"
|
||||
}`}
|
||||
>
|
||||
{l}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,6 @@ import Animated, {
|
||||
useScrollViewOffset,
|
||||
} from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Chromecast } from "./Chromecast";
|
||||
|
||||
const HEADER_HEIGHT = 400;
|
||||
|
||||
@@ -33,14 +32,14 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
translateY: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
||||
),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
scrollOffset.value,
|
||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||
[2, 1, 1],
|
||||
[2, 1, 1]
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -73,15 +72,6 @@ export const ParallaxScrollView: React.FC<Props> = ({
|
||||
/>
|
||||
</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 && (
|
||||
<View className="absolute top-[250px] h-[130px] left-0 w-full z-40 px-4 flex justify-center items-center">
|
||||
{logo}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { markAsPlayed } from "@/utils/jellyfin/playstate/markAsPlayed";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useAtom } from "jotai";
|
||||
import React from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
@@ -41,7 +40,6 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
{item.UserData?.Played ? (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
await markAsNotPlayed({
|
||||
api: api,
|
||||
itemId: item?.Id,
|
||||
@@ -57,7 +55,6 @@ export const PlayedStatus: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
await markAsPlayed({
|
||||
api: api,
|
||||
item: item,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "./common/Text";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
@@ -22,14 +21,14 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
const subtitleStreams = useMemo(
|
||||
() =>
|
||||
item.MediaSources?.[0].MediaStreams?.filter(
|
||||
(x) => x.Type === "Subtitle",
|
||||
(x) => x.Type === "Subtitle"
|
||||
) ?? [],
|
||||
[item],
|
||||
[item]
|
||||
);
|
||||
|
||||
const selectedSubtitleSteam = useMemo(
|
||||
() => subtitleStreams.find((x) => x.Index === selected),
|
||||
[subtitleStreams, selected],
|
||||
[subtitleStreams, selected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,55 +43,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
|
||||
if (subtitleStreams.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="flex flex-row items-center justify-between" {...props}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<View className="flex flex-col mb-2">
|
||||
<Text className="opacity-50 mb-1 text-xs">Subtitles</Text>
|
||||
<View className="flex flex-row">
|
||||
<TouchableOpacity className="bg-neutral-900 max-w-32 h-12 rounded-2xl border-neutral-900 border px-3 py-2 flex flex-row items-center justify-between">
|
||||
<Text className="">
|
||||
{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>
|
||||
<View
|
||||
className="flex flex-row items-center justify-between"
|
||||
{...props}
|
||||
></View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Text } from "@/components/common/Text";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { useRouter } from "expo-router";
|
||||
import * as Haptics from "expo-haptics";
|
||||
|
||||
interface Props extends TouchableOpacityProps {
|
||||
item: BaseItemDto;
|
||||
@@ -23,8 +22,6 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
|
||||
if (item.Type === "Series") {
|
||||
router.push(`/series/${item.Id}`);
|
||||
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 { useAtom } from "jotai";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { chromecastProfile } from "@/utils/profiles/chromecast";
|
||||
import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import CastContext, {
|
||||
PlayServicesState,
|
||||
useCastDevice,
|
||||
useRemoteMediaClient,
|
||||
} from "react-native-google-cast";
|
||||
import { currentlyPlayingItemAtom, playingAtom } from "../CurrentlyPlayingBar";
|
||||
import { useActionSheet } from "@expo/react-native-action-sheet";
|
||||
import ios from "@/utils/profiles/ios";
|
||||
@@ -41,40 +35,14 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const castDevice = useCastDevice();
|
||||
const [, setCp] = useAtom(currentlyPlayingItemAtom);
|
||||
const [, setPlaying] = useAtom(playingAtom);
|
||||
|
||||
const client = useRemoteMediaClient();
|
||||
const { showActionSheetWithOptions } = useActionSheet();
|
||||
|
||||
const openSelect = () => {
|
||||
if (!castDevice?.deviceId) {
|
||||
play("device");
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
play("device");
|
||||
return;
|
||||
};
|
||||
|
||||
const play = async (type: "device" | "cast") => {
|
||||
@@ -93,37 +61,16 @@ export const SongsListItem: React.FC<Props> = ({
|
||||
item,
|
||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks || 0,
|
||||
sessionData,
|
||||
deviceProfile: castDevice?.deviceId ? chromecastProfile : ios,
|
||||
deviceProfile: ios,
|
||||
});
|
||||
|
||||
if (!url || !item) return;
|
||||
|
||||
if (type === "cast" && client) {
|
||||
await CastContext.getPlayServicesState().then((state) => {
|
||||
if (state && state !== PlayServicesState.SUCCESS)
|
||||
CastContext.showPlayServicesErrorDialog(state);
|
||||
else {
|
||||
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);
|
||||
}
|
||||
setCp({
|
||||
item,
|
||||
playbackUrl: url,
|
||||
});
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useRouter } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import ContinueWatchingPoster from "../ContinueWatchingPoster";
|
||||
import { ItemCardText } from "../ItemCardText";
|
||||
import { HorizontalScroll } from "../common/HorrizontalScroll";
|
||||
@@ -40,7 +39,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items;
|
||||
@@ -51,7 +50,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
const selectedSeasonId: string | null = useMemo(
|
||||
() =>
|
||||
seasons?.find((season: any) => season.IndexNumber === seasonIndex)?.Id,
|
||||
[seasons, seasonIndex],
|
||||
[seasons, seasonIndex]
|
||||
);
|
||||
|
||||
const { data: episodes } = useQuery({
|
||||
@@ -70,7 +69,7 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
headers: {
|
||||
Authorization: `MediaBrowser DeviceId="${api.deviceInfo.id}", Token="${api.accessToken}"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.data.Items as BaseItemDto[];
|
||||
@@ -80,36 +79,6 @@ export const SeasonPicker: React.FC<Props> = ({ item }) => {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<View className="mt-4">
|
||||
<HorizontalScroll<BaseItemDto>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { Linking, Switch, TouchableOpacity, View } from "react-native";
|
||||
import * as DropdownMenu from "zeego/dropdown-menu";
|
||||
import { Text } from "../common/Text";
|
||||
import { Loader } from "../Loader";
|
||||
|
||||
@@ -165,48 +164,6 @@ export const SettingToggles: React.FC = () => {
|
||||
supports.
|
||||
</Text>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
22
i18n.ts
22
i18n.ts
@@ -1,22 +0,0 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import en from "./translations/en.json";
|
||||
import sv from "./translations/sv.json";
|
||||
import { getLocales } from "expo-localization";
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
compatibilityJSON: "v3",
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
sv: { translation: sv },
|
||||
},
|
||||
|
||||
lng: getLocales()[0].languageCode || "en",
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
24
package.json
24
package.json
@@ -15,15 +15,14 @@
|
||||
"preset": "jest-expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@config-plugins/ffmpeg-kit-react-native": "^8.0.0",
|
||||
"@expo/react-native-action-sheet": "^4.1.0",
|
||||
"@expo/vector-icons": "^14.0.2",
|
||||
"@gorhom/bottom-sheet": "^4",
|
||||
"@jellyfin/sdk": "^0.10.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-menu/menu": "^1.1.2",
|
||||
"@react-native-tvos/config-tv": "^0.0.10",
|
||||
"@react-navigation/native": "^6.0.2",
|
||||
"@shopify/flash-list": "1.6.4",
|
||||
"@tanstack/react-query": "^5.51.16",
|
||||
@@ -37,36 +36,27 @@
|
||||
"expo-dev-client": "~4.0.23",
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-font": "~12.0.9",
|
||||
"expo-haptics": "~13.0.1",
|
||||
"expo-image": "~1.12.13",
|
||||
"expo-keep-awake": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-localization": "~15.0.3",
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"expo-router": "~3.5.23",
|
||||
"expo-screen-orientation": "~7.0.5",
|
||||
"expo-sensors": "~13.0.9",
|
||||
"expo-splash-screen": "~0.27.5",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"expo-system-ui": "~3.0.7",
|
||||
"expo-updates": "~0.25.22",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"ffmpeg-kit-react-native": "^6.0.2",
|
||||
"i18next": "^23.13.0",
|
||||
"jotai": "^2.9.1",
|
||||
"lodash": "^4.17.21",
|
||||
"nativewind": "^2.0.11",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-native": "0.74.5",
|
||||
"react-native": "npm:react-native-tvos@latest",
|
||||
"react-native-circular-progress": "^1.4.0",
|
||||
"react-native-compressor": "^1.8.25",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
"react-native-google-cast": "^4.8.2",
|
||||
"react-native-ios-context-menu": "^2.5.1",
|
||||
"react-native-ios-utilities": "^4.4.5",
|
||||
"react-native-ios-utilities": "^4.5.0",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
"react-native-reanimated-carousel": "4.0.0-alpha.12",
|
||||
"react-native-safe-area-context": "4.10.5",
|
||||
@@ -79,7 +69,6 @@
|
||||
"tailwindcss": "3.3.2",
|
||||
"use-debounce": "^10.0.3",
|
||||
"uuid": "^10.0.0",
|
||||
"zeego": "^1.10.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -92,5 +81,12 @@
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "~5.3.3"
|
||||
},
|
||||
"expo": {
|
||||
"install": {
|
||||
"exclude": [
|
||||
"react-native"
|
||||
]
|
||||
}
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isLoaded } from "expo-font";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { router, useSegments } from "expo-router";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, {
|
||||
@@ -31,15 +29,14 @@ interface JellyfinContextValue {
|
||||
}
|
||||
|
||||
const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const getOrSetDeviceId = async () => {
|
||||
let deviceId = await AsyncStorage.getItem("deviceId");
|
||||
let deviceId = null;
|
||||
|
||||
if (!deviceId) {
|
||||
deviceId = uuid.v4() as string;
|
||||
await AsyncStorage.setItem("deviceId", deviceId);
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
@@ -58,7 +55,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.6.1" },
|
||||
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 discoverServers = async (url: string): Promise<Server[]> => {
|
||||
const servers =
|
||||
await jellyfin?.discovery.getRecommendedServerCandidates(url);
|
||||
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||
url
|
||||
);
|
||||
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");
|
||||
|
||||
setApi(apiInstance);
|
||||
await AsyncStorage.setItem("serverUrl", server.address);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to set server:", error);
|
||||
@@ -88,7 +85,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const removeServerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await AsyncStorage.removeItem("serverUrl");
|
||||
setApi(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -110,9 +106,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
if (auth.data.AccessToken && auth.data.User) {
|
||||
setUser(auth.data.User);
|
||||
await AsyncStorage.setItem("user", JSON.stringify(auth.data.User));
|
||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||
await AsyncStorage.setItem("token", auth.data?.AccessToken);
|
||||
} else {
|
||||
throw new Error("Invalid username or password");
|
||||
}
|
||||
@@ -124,7 +118,6 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await AsyncStorage.removeItem("token");
|
||||
setUser(null);
|
||||
},
|
||||
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 = {
|
||||
discoverServers,
|
||||
setServer: (server) => setServerMutation.mutateAsync(server),
|
||||
@@ -171,7 +134,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
logout: () => logoutMutation.mutateAsync(),
|
||||
};
|
||||
|
||||
useProtectedRoute(user, isLoading || isFetching);
|
||||
useProtectedRoute(user);
|
||||
|
||||
return (
|
||||
<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,38 +0,0 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Username is required",
|
||||
"error_title": "Error",
|
||||
"url_error_message": "URL needs to start with http or https.",
|
||||
"login": "Log in",
|
||||
"login_subtitle": "Log in to any user account",
|
||||
"username_placeholder": "Username",
|
||||
"password_placeholder": "Password",
|
||||
"login_button": "Log in"
|
||||
},
|
||||
"server": {
|
||||
"server_label": "Server: {{serverURL}}",
|
||||
"change_server": "Change server",
|
||||
"connect_to_server": "Connect to your Jellyfin server",
|
||||
"server_url_placeholder": "Server URL",
|
||||
"server_url_hint": "Server URL requires http or https",
|
||||
"connect_button": "Connect"
|
||||
},
|
||||
"home": {
|
||||
"home": "Home",
|
||||
"noInternet": "No Internet",
|
||||
"noInternetMessage": "No worries, you can still watch\ndownloaded content.",
|
||||
"goToDownloads": "Go to downloads",
|
||||
"oops": "Oops!",
|
||||
"errorMessage": "Something went wrong.\nPlease log out and in again.",
|
||||
"continueWatching": "Continue Watching",
|
||||
"nextUp": "Next Up",
|
||||
"recentlyAddedMovies": "Recently Added in Movies",
|
||||
"recentlyAddedTVShows": "Recently Added in TV-Shows",
|
||||
"suggestions": "Suggestions"
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Home",
|
||||
"search": "Search",
|
||||
"library": "Library"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"login": {
|
||||
"username_required": "Användarnamn krävs",
|
||||
"error_title": "Fel",
|
||||
"url_error_message": "URL måste börja med http eller https.",
|
||||
"login_title": "Logga in",
|
||||
"login_subtitle": "Logga in på ett användarkonto",
|
||||
"username_placeholder": "Användarnamn",
|
||||
"password_placeholder": "Lösenord",
|
||||
"login_button": "Logga in"
|
||||
},
|
||||
"server": {
|
||||
"server_label": "Server: {{serverURL}}",
|
||||
"change_server": "Byt server",
|
||||
"connect_to_server": "Anslut till din Jellyfin-server",
|
||||
"server_url_placeholder": "Server URL",
|
||||
"server_url_hint": "Server URL kräver http eller https",
|
||||
"connect_button": "Anslut"
|
||||
},
|
||||
"home": {
|
||||
"home": "Hem",
|
||||
"noInternet": "Ingen Internet",
|
||||
"noInternetMessage": "Ingen fara, du kan fortfarande titta\npå nedladdat innehåll.",
|
||||
"goToDownloads": "Gå till nedladdningar",
|
||||
"oops": "Hoppsan!",
|
||||
"errorMessage": "Något gick fel.\nLogga ut och in igen.",
|
||||
"continueWatching": "Fortsätt titta",
|
||||
"nextUp": "Nästa upp",
|
||||
"recentlyAddedMovies": "Nyligen tillagt i Filmer",
|
||||
"recentlyAddedTVShows": "Nyligen tillagt i TV-Serier",
|
||||
"suggestions": "Förslag"
|
||||
},
|
||||
"tabs": {
|
||||
"home": "Hem",
|
||||
"search": "Sök",
|
||||
"library": "Bibliotek"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect } from "react";
|
||||
import { getLocales } from "expo-localization";
|
||||
|
||||
type Settings = {
|
||||
autoRotate?: boolean;
|
||||
@@ -11,59 +8,29 @@ type Settings = {
|
||||
deviceProfile?: "Expo" | "Native" | "Old";
|
||||
forceDirectPlay?: boolean;
|
||||
mediaListCollectionIds?: string[];
|
||||
preferedLanguage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* The settings atom is a Jotai atom that stores the user's settings.
|
||||
* It is initialized with a default value of null, which indicates that the settings have not been loaded yet.
|
||||
* The settings are loaded from AsyncStorage when the atom is read for the first time.
|
||||
*
|
||||
*/
|
||||
|
||||
// Utility function to load settings from AsyncStorage
|
||||
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: [],
|
||||
preferedLanguage: getLocales()[0] || "en",
|
||||
};
|
||||
// Default settings
|
||||
const defaultSettings: Settings = {
|
||||
autoRotate: true,
|
||||
forceLandscapeInVideoPlayer: false,
|
||||
openFullScreenVideoPlayerByDefault: true,
|
||||
usePopularPlugin: false,
|
||||
deviceProfile: "Expo",
|
||||
forceDirectPlay: false,
|
||||
mediaListCollectionIds: [],
|
||||
};
|
||||
|
||||
// Utility function to save settings to AsyncStorage
|
||||
const saveSettings = async (settings: Settings) => {
|
||||
const jsonValue = JSON.stringify(settings);
|
||||
await AsyncStorage.setItem("settings", jsonValue);
|
||||
};
|
||||
// Create an atom to store the settings in memory, initialized with default settings
|
||||
const settingsAtom = atom<Settings>(defaultSettings);
|
||||
|
||||
// Create an atom to store the settings in memory
|
||||
const settingsAtom = atom<Settings | null>(null);
|
||||
|
||||
// A hook to manage settings, loading them on initial mount and providing a way to update them
|
||||
// A hook to manage settings, providing a way to update them
|
||||
export const useSettings = () => {
|
||||
const [settings, setSettings] = useAtom(settingsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings === null) {
|
||||
loadSettings().then(setSettings);
|
||||
}
|
||||
}, [settings, setSettings]);
|
||||
|
||||
const updateSettings = async (update: Partial<Settings>) => {
|
||||
if (settings) {
|
||||
const newSettings = { ...settings, ...update };
|
||||
setSettings(newSettings);
|
||||
await saveSettings(newSettings);
|
||||
}
|
||||
const updateSettings = (update: Partial<Settings>) => {
|
||||
const newSettings = { ...settings, ...update };
|
||||
setSettings(newSettings);
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||
@@ -10,8 +10,7 @@ interface LogEntry {
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const asyncStorage = createJSONStorage(() => AsyncStorage);
|
||||
const logsAtom = atomWithStorage("logs", [], asyncStorage);
|
||||
const logsAtom = atom([]);
|
||||
|
||||
export const writeToLog = async (
|
||||
level: LogLevel,
|
||||
@@ -25,23 +24,16 @@ export const writeToLog = async (
|
||||
data: data,
|
||||
};
|
||||
|
||||
const currentLogs = await AsyncStorage.getItem("logs");
|
||||
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
|
||||
const logs: LogEntry[] = [];
|
||||
logs.push(newEntry);
|
||||
|
||||
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[]> => {
|
||||
const logs = await AsyncStorage.getItem("logs");
|
||||
return logs ? JSON.parse(logs) : [];
|
||||
return [];
|
||||
};
|
||||
|
||||
export const clearLogs = async () => {
|
||||
await AsyncStorage.removeItem("logs");
|
||||
};
|
||||
export const clearLogs = async () => {};
|
||||
|
||||
export default logsAtom;
|
||||
|
||||
Reference in New Issue
Block a user