mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-19 07:46:24 +00:00
fix: change to mmkv and fix downloads with VLC
This commit is contained in:
@@ -6,32 +6,42 @@ import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
|
||||
import { queueAtom } from "@/utils/atoms/queue";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { router } from "expo-router";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const downloads: React.FC = () => {
|
||||
export default function page() {
|
||||
const [queue, setQueue] = useAtom(queueAtom);
|
||||
const { removeProcess, downloadedFiles } = useDownload();
|
||||
|
||||
const router = useRouter();
|
||||
const [settings] = useSettings();
|
||||
|
||||
const movies = useMemo(
|
||||
() => downloadedFiles?.filter((f) => f.item.Type === "Movie") || [],
|
||||
[downloadedFiles]
|
||||
);
|
||||
const movies = useMemo(() => {
|
||||
try {
|
||||
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||
} catch {
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const groupedBySeries = useMemo(() => {
|
||||
const episodes = downloadedFiles?.filter((f) => f.item.Type === "Episode");
|
||||
const series: { [key: string]: DownloadedItem[] } = {};
|
||||
episodes?.forEach((e) => {
|
||||
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
||||
series[e.item.SeriesName!].push(e);
|
||||
});
|
||||
return Object.values(series);
|
||||
try {
|
||||
const episodes = downloadedFiles?.filter(
|
||||
(f) => f.item.Type === "Episode"
|
||||
);
|
||||
const series: { [key: string]: DownloadedItem[] } = {};
|
||||
episodes?.forEach((e) => {
|
||||
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
||||
series[e.item.SeriesName!].push(e);
|
||||
});
|
||||
return Object.values(series);
|
||||
} catch {
|
||||
migration_20241124();
|
||||
return [];
|
||||
}
|
||||
}, [downloadedFiles]);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -121,6 +131,24 @@ const downloads: React.FC = () => {
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default downloads;
|
||||
function migration_20241124() {
|
||||
const router = useRouter();
|
||||
const { deleteAllFiles } = useDownload();
|
||||
Alert.alert(
|
||||
"New app version requires re-download",
|
||||
"The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
|
||||
[
|
||||
{
|
||||
text: "Back",
|
||||
onPress: () => router.back(),
|
||||
},
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => await deleteAllFiles(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,14 +119,14 @@ export default function page() {
|
||||
queryFn: async () => {
|
||||
console.log("Offline:", offline);
|
||||
if (offline) {
|
||||
const item = await getDownloadedItem(itemId);
|
||||
if (!item?.mediaSource) return null;
|
||||
const data = await getDownloadedItem(itemId);
|
||||
if (!data?.mediaSource) return null;
|
||||
|
||||
const url = await getDownloadedFileUrl(item.item.Id!);
|
||||
const url = await getDownloadedFileUrl(data.item.Id!);
|
||||
|
||||
if (item)
|
||||
return {
|
||||
mediaSource: item.mediaSource,
|
||||
mediaSource: data.mediaSource,
|
||||
url,
|
||||
sessionId: undefined,
|
||||
};
|
||||
@@ -165,13 +165,13 @@ export default function page() {
|
||||
|
||||
const togglePlay = useCallback(
|
||||
async (ms: number) => {
|
||||
if (!api || !stream) return;
|
||||
if (!api) return;
|
||||
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (isPlaying) {
|
||||
await videoRef.current?.pause();
|
||||
|
||||
if (!offline) {
|
||||
if (!offline && stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
@@ -189,7 +189,7 @@ export default function page() {
|
||||
console.log("Actually marked as paused");
|
||||
} else {
|
||||
videoRef.current?.play();
|
||||
if (!offline) {
|
||||
if (!offline && stream) {
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
@@ -235,6 +235,7 @@ export default function page() {
|
||||
|
||||
const reportPlaybackStopped = useCallback(async () => {
|
||||
if (offline) return;
|
||||
|
||||
const currentTimeInTicks = msToTicks(progress.value);
|
||||
await getPlaystateApi(api!).onPlaybackStopped({
|
||||
itemId: item?.Id!,
|
||||
@@ -245,9 +246,10 @@ export default function page() {
|
||||
}, [api, item, mediaSourceId, stream]);
|
||||
|
||||
const reportPlaybackStart = useCallback(async () => {
|
||||
if (!api || !stream) return;
|
||||
if (offline) return;
|
||||
await getPlaystateApi(api).onPlaybackStart({
|
||||
|
||||
if (!stream) return;
|
||||
await getPlaystateApi(api!).onPlaybackStart({
|
||||
itemId: item?.Id!,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
@@ -261,7 +263,6 @@ export default function page() {
|
||||
async (data: ProgressUpdatePayload) => {
|
||||
if (isSeeking.value === true) return;
|
||||
if (isPlaybackStopped === true) return;
|
||||
if (!item?.Id || !api || !stream) return;
|
||||
|
||||
const { currentTime } = data.nativeEvent;
|
||||
|
||||
@@ -275,7 +276,9 @@ export default function page() {
|
||||
|
||||
const currentTimeInTicks = msToTicks(currentTime);
|
||||
|
||||
await getPlaystateApi(api).onPlaybackProgress({
|
||||
if (!item?.Id || !stream) return;
|
||||
|
||||
await getPlaystateApi(api!).onPlaybackProgress({
|
||||
itemId: item.Id,
|
||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
||||
@@ -343,21 +346,20 @@ export default function page() {
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!stream || !item) return null;
|
||||
|
||||
// Preselection of audio and subtitle tracks.
|
||||
|
||||
let initOptions = ["--sub-text-scale=60"];
|
||||
let externalTrack = { name: "", DeliveryUrl: "" };
|
||||
|
||||
const allSubs =
|
||||
stream.mediaSource.MediaStreams?.filter((sub) => sub.Type === "Subtitle") ||
|
||||
[];
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(sub) => sub.Type === "Subtitle"
|
||||
) || [];
|
||||
const chosenSubtitleTrack = allSubs.find(
|
||||
(sub) => sub.Index === subtitleIndex
|
||||
);
|
||||
const allAudio =
|
||||
stream.mediaSource.MediaStreams?.filter(
|
||||
stream?.mediaSource.MediaStreams?.filter(
|
||||
(audio) => audio.Type === "Audio"
|
||||
) || [];
|
||||
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
|
||||
@@ -375,9 +377,8 @@ export default function page() {
|
||||
};
|
||||
}
|
||||
|
||||
if (!chosenAudioTrack) throw new Error("No audio track found");
|
||||
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
if (chosenAudioTrack)
|
||||
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
|
||||
} else {
|
||||
// Transcoded playback CASE
|
||||
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
|
||||
@@ -388,6 +389,15 @@ export default function page() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!item || !stream)
|
||||
return (
|
||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
||||
<Text className="text-white">
|
||||
<Loader />
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -434,7 +444,7 @@ export default function page() {
|
||||
|
||||
{videoRef.current && (
|
||||
<Controls
|
||||
mediaSource={stream.mediaSource}
|
||||
mediaSource={stream?.mediaSource}
|
||||
item={item}
|
||||
videoRef={videoRef}
|
||||
togglePlay={togglePlay}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||
import {
|
||||
getOrSetDeviceId,
|
||||
getTokenFromStoraage,
|
||||
getTokenFromStorage,
|
||||
JellyfinProvider,
|
||||
} from "@/providers/JellyfinProvider";
|
||||
import { JobQueueProvider } from "@/providers/JobQueueProvider";
|
||||
@@ -10,6 +10,7 @@ import { orientationAtom } from "@/utils/atoms/orientation";
|
||||
import { Settings, useSettings } from "@/utils/atoms/settings";
|
||||
import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
|
||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
completeHandler,
|
||||
download,
|
||||
} from "@kesha-antonov/react-native-background-downloader";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as BackgroundFetch from "expo-background-fetch";
|
||||
@@ -35,10 +35,10 @@ import * as TaskManager from "expo-task-manager";
|
||||
import { Provider as JotaiProvider, useAtom } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Appearance, AppState } from "react-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import "react-native-reanimated";
|
||||
import { Toaster } from "sonner-native";
|
||||
import { SystemBars } from "react-native-edge-to-edge";
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
@@ -86,7 +86,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const settingsData = await AsyncStorage.getItem("settings");
|
||||
const settingsData = storage.getString("settings");
|
||||
|
||||
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
@@ -96,8 +96,8 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
if (!settings?.autoDownload || !url)
|
||||
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||
|
||||
const token = await getTokenFromStoraage();
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
const token = getTokenFromStorage();
|
||||
const deviceId = getOrSetDeviceId();
|
||||
const baseDirectory = FileSystem.documentDirectory;
|
||||
|
||||
if (!token || !deviceId || !baseDirectory)
|
||||
@@ -177,7 +177,7 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
|
||||
const checkAndRequestPermissions = async () => {
|
||||
try {
|
||||
const hasAskedBefore = await AsyncStorage.getItem(
|
||||
const hasAskedBefore = storage.getString(
|
||||
"hasAskedForNotificationPermission"
|
||||
);
|
||||
|
||||
@@ -192,7 +192,7 @@ const checkAndRequestPermissions = async () => {
|
||||
console.log("Notification permissions denied.");
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem("hasAskedForNotificationPermission", "true");
|
||||
storage.set("hasAskedForNotificationPermission", "true");
|
||||
} else {
|
||||
console.log("Already asked for notification permissions before.");
|
||||
}
|
||||
@@ -365,9 +365,9 @@ function Layout() {
|
||||
);
|
||||
}
|
||||
|
||||
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: BaseItemDto[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
@@ -379,7 +379,7 @@ async function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Failed to save downloaded item information:", error);
|
||||
console.error("Failed to save downloaded item information:", error);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { useCallback } from "react";
|
||||
|
||||
const useImageStorage = () => {
|
||||
const saveBase64Image = useCallback(async (base64: string, key: string) => {
|
||||
try {
|
||||
// Save the base64 string to AsyncStorage
|
||||
// Save the base64 string to storage
|
||||
storage.set(key, base64);
|
||||
} catch (error) {
|
||||
console.error("Error saving image:", error);
|
||||
@@ -69,7 +67,7 @@ const useImageStorage = () => {
|
||||
|
||||
const loadImage = useCallback(async (key: string) => {
|
||||
try {
|
||||
// Retrieve the base64 string from AsyncStorage
|
||||
// Retrieve the base64 string from storage
|
||||
const base64Image = storage.getString(key);
|
||||
if (base64Image !== null) {
|
||||
// Set the loaded image state
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useAtomValue } 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 { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import {
|
||||
BaseItemDto,
|
||||
MediaSourceInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { useRouter } from "expo-router";
|
||||
import { JobStatus } from "@/utils/optimize-server";
|
||||
import { FFmpegKit, FFmpegKitConfig } from "ffmpeg-kit-react-native";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import useImageStorage from "./useImageStorage";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
/**
|
||||
* Custom hook for remuxing HLS to MP4 using FFmpeg.
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
download,
|
||||
setConfig,
|
||||
} from "@kesha-antonov/react-native-background-downloader";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import MMKV from "react-native-mmkv";
|
||||
import {
|
||||
focusManager,
|
||||
QueryClient,
|
||||
@@ -45,6 +45,7 @@ import { apiAtom } from "./JellyfinProvider";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import useImageStorage from "@/hooks/useImageStorage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
export type DownloadedItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
@@ -109,7 +110,6 @@ function useDownloadProvider() {
|
||||
url,
|
||||
});
|
||||
|
||||
// Local downloading processes that are still valid
|
||||
const downloadingProcesses = processes
|
||||
.filter((p) => p.status === "downloading")
|
||||
.filter((p) => jobs.some((j) => j.id === p.id));
|
||||
@@ -120,8 +120,6 @@ function useDownloadProvider() {
|
||||
|
||||
setProcesses([...updatedProcesses, ...downloadingProcesses]);
|
||||
|
||||
// Go though new jobs and compare them to old jobs
|
||||
// if new job is now completed, start download.
|
||||
for (let job of jobs) {
|
||||
const process = processes.find((p) => p.id === job.id);
|
||||
if (
|
||||
@@ -314,7 +312,6 @@ function useDownloadProvider() {
|
||||
const fileExtension = mediaSource.TranscodingContainer;
|
||||
const deviceId = await getOrSetDeviceId();
|
||||
|
||||
// Save poster to disk
|
||||
const itemImage = getItemImage({
|
||||
item,
|
||||
api,
|
||||
@@ -324,7 +321,6 @@ function useDownloadProvider() {
|
||||
});
|
||||
await saveImage(item.Id, itemImage?.uri);
|
||||
|
||||
// POST to start optimization job on the server
|
||||
const response = await axios.post(
|
||||
settings?.optimizedVersionsServerUrl + "optimize-version",
|
||||
{
|
||||
@@ -391,7 +387,7 @@ function useDownloadProvider() {
|
||||
const deleteAllFiles = async (): Promise<void> => {
|
||||
try {
|
||||
await deleteLocalFiles();
|
||||
await removeDownloadedItemsFromStorage();
|
||||
removeDownloadedItemsFromStorage();
|
||||
await cancelAllServerJobs();
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
toast.success("All files, folders, and jobs deleted successfully");
|
||||
@@ -421,12 +417,12 @@ function useDownloadProvider() {
|
||||
}
|
||||
};
|
||||
|
||||
const removeDownloadedItemsFromStorage = async (): Promise<void> => {
|
||||
const removeDownloadedItemsFromStorage = (): void => {
|
||||
try {
|
||||
await AsyncStorage.removeItem("downloadedItems");
|
||||
storage.delete("downloadedItems");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to remove downloadedItems from AsyncStorage:",
|
||||
"Failed to remove downloadedItems from storage:",
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
@@ -482,27 +478,25 @@ function useDownloadProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
let items = JSON.parse(downloadedItems);
|
||||
items = items.filter((item: any) => item.Id !== id);
|
||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to delete file and AsyncStorage entry for ID ${id}:`,
|
||||
`Failed to delete file and storage entry for ID ${id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
async function getDownloadedItem(
|
||||
itemId: string
|
||||
): Promise<DownloadedItem | null> {
|
||||
function getDownloadedItem(itemId: string): DownloadedItem | null {
|
||||
try {
|
||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
const items: DownloadedItem[] = JSON.parse(downloadedItems);
|
||||
const item = items.find((i) => i.item.Id === itemId);
|
||||
@@ -515,9 +509,9 @@ function useDownloadProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllDownloadedItems(): Promise<DownloadedItem[]> {
|
||||
function getAllDownloadedItems(): DownloadedItem[] {
|
||||
try {
|
||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
if (downloadedItems) {
|
||||
return JSON.parse(downloadedItems) as DownloadedItem[];
|
||||
} else {
|
||||
@@ -529,9 +523,9 @@ function useDownloadProvider() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
function saveDownloadedItemInfo(item: BaseItemDto) {
|
||||
try {
|
||||
const downloadedItems = await AsyncStorage.getItem("downloadedItems");
|
||||
const downloadedItems = storage.getString("downloadedItems");
|
||||
let items: DownloadedItem[] = downloadedItems
|
||||
? JSON.parse(downloadedItems)
|
||||
: [];
|
||||
@@ -555,8 +549,8 @@ function useDownloadProvider() {
|
||||
|
||||
deleteDownloadItemInfoFromDiskTmp(item.Id!);
|
||||
|
||||
await AsyncStorage.setItem("downloadedItems", JSON.stringify(items));
|
||||
await queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
storage.set("downloadedItems", JSON.stringify(items));
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] });
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useInterval } from "@/hooks/useInterval";
|
||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||
import { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { router, useSegments } from "expo-router";
|
||||
@@ -18,6 +17,8 @@ import React, {
|
||||
} from "react";
|
||||
import { Platform } from "react-native";
|
||||
import uuid from "react-native-uuid";
|
||||
import MMKV from "react-native-mmkv";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
@@ -48,7 +49,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const id = await getOrSetDeviceId();
|
||||
const id = getOrSetDeviceId();
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
@@ -138,8 +139,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const { AccessToken, User } = authResponse.data;
|
||||
api.accessToken = AccessToken;
|
||||
setUser(User);
|
||||
await AsyncStorage.setItem("token", AccessToken);
|
||||
await AsyncStorage.setItem("user", JSON.stringify(User));
|
||||
storage.set("token", AccessToken);
|
||||
storage.set("user", JSON.stringify(User));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -172,7 +173,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
||||
|
||||
setApi(apiInstance);
|
||||
await AsyncStorage.setItem("serverUrl", server.address);
|
||||
storage.set("serverUrl", server.address);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to set server:", error);
|
||||
@@ -181,7 +182,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const removeServerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await AsyncStorage.removeItem("serverUrl");
|
||||
storage.delete("serverUrl");
|
||||
setApi(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -204,9 +205,9 @@ 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));
|
||||
storage.set("user", JSON.stringify(auth.data.User));
|
||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||
await AsyncStorage.setItem("token", auth.data?.AccessToken);
|
||||
storage.set("token", auth.data?.AccessToken);
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -241,7 +242,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await AsyncStorage.removeItem("token");
|
||||
storage.delete("token");
|
||||
setUser(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -258,13 +259,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const token = await getTokenFromStoraage();
|
||||
const serverUrl = await getServerUrlFromStorage();
|
||||
const user = JSON.parse(
|
||||
(await getUserFromStorage()) as string
|
||||
) as UserDto;
|
||||
|
||||
if (serverUrl && token && user.Id && jellyfin) {
|
||||
const token = getTokenFromStorage();
|
||||
const serverUrl = getServerUrlFromStorage();
|
||||
const user = getUserFromStorage();
|
||||
if (serverUrl && token && user?.Id && jellyfin) {
|
||||
const apiInstance = jellyfin.createApi(serverUrl, token);
|
||||
setApi(apiInstance);
|
||||
setUser(user);
|
||||
@@ -273,6 +271,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
@@ -321,24 +320,32 @@ function useProtectedRoute(user: UserDto | null, loading = false) {
|
||||
}, [user, segments, loading]);
|
||||
}
|
||||
|
||||
export async function getTokenFromStoraage() {
|
||||
return await AsyncStorage.getItem("token");
|
||||
export function getTokenFromStorage(): string | null {
|
||||
return storage.getString("token") || null;
|
||||
}
|
||||
|
||||
export async function getUserFromStorage() {
|
||||
return await AsyncStorage.getItem("user");
|
||||
export function getUserFromStorage(): UserDto | null {
|
||||
const userStr = storage.getString("user");
|
||||
if (userStr) {
|
||||
try {
|
||||
return JSON.parse(userStr) as UserDto;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getServerUrlFromStorage() {
|
||||
return await AsyncStorage.getItem("serverUrl");
|
||||
export function getServerUrlFromStorage(): string | null {
|
||||
return storage.getString("serverUrl") || null;
|
||||
}
|
||||
|
||||
export async function getOrSetDeviceId() {
|
||||
let deviceId = await AsyncStorage.getItem("deviceId");
|
||||
export function getOrSetDeviceId(): string {
|
||||
let deviceId = storage.getString("deviceId");
|
||||
|
||||
if (!deviceId) {
|
||||
deviceId = uuid.v4() as string;
|
||||
await AsyncStorage.setItem("deviceId", deviceId);
|
||||
storage.set("deviceId", deviceId);
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { storage } from "../mmkv";
|
||||
|
||||
export enum SortByOption {
|
||||
Default = "Default",
|
||||
@@ -68,9 +68,6 @@ export const sortOrderAtom = atom<SortOrderOption[]>([
|
||||
SortOrderOption.Ascending,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Sort preferences with persistence
|
||||
*/
|
||||
export interface SortPreference {
|
||||
[libraryId: string]: SortByOption;
|
||||
}
|
||||
@@ -86,15 +83,15 @@ export const sortByPreferenceAtom = atomWithStorage<SortPreference>(
|
||||
"sortByPreference",
|
||||
defaultSortPreference,
|
||||
{
|
||||
getItem: async (key) => {
|
||||
const value = await AsyncStorage.getItem(key);
|
||||
getItem: (key) => {
|
||||
const value = storage.getString(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
setItem: (key, value) => {
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
await AsyncStorage.removeItem(key);
|
||||
removeItem: (key) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -103,20 +100,19 @@ export const sortOrderPreferenceAtom = atomWithStorage<SortOrderPreference>(
|
||||
"sortOrderPreference",
|
||||
defaultSortOrderPreference,
|
||||
{
|
||||
getItem: async (key) => {
|
||||
const value = await AsyncStorage.getItem(key);
|
||||
getItem: (key) => {
|
||||
const value = storage.getString(key);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
setItem: async (key, value) => {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
setItem: (key, value) => {
|
||||
storage.set(key, JSON.stringify(value));
|
||||
},
|
||||
removeItem: async (key) => {
|
||||
await AsyncStorage.removeItem(key);
|
||||
removeItem: (key) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Helper functions to get and set sort preferences
|
||||
export const getSortByPreference = (
|
||||
libraryId: string,
|
||||
preferences: SortPreference
|
||||
@@ -130,4 +126,3 @@ export const getSortOrderPreference = (
|
||||
) => {
|
||||
return preferences?.[libraryId] || null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface Job {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useEffect } from "react";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { storage } from "../mmkv";
|
||||
|
||||
export type DownloadQuality = "original" | "high" | "low";
|
||||
|
||||
@@ -75,15 +75,8 @@ export type Settings = {
|
||||
downloadMethod: "optimized" | "remux";
|
||||
autoDownload: boolean;
|
||||
};
|
||||
/**
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
const loadSettings = async (): Promise<Settings> => {
|
||||
const loadSettings = (): Settings => {
|
||||
const defaultValues: Settings = {
|
||||
autoRotate: true,
|
||||
forceLandscapeInVideoPlayer: false,
|
||||
@@ -113,7 +106,7 @@ const loadSettings = async (): Promise<Settings> => {
|
||||
};
|
||||
|
||||
try {
|
||||
const jsonValue = await AsyncStorage.getItem("settings");
|
||||
const jsonValue = storage.getString("settings");
|
||||
const loadedValues: Partial<Settings> =
|
||||
jsonValue != null ? JSON.parse(jsonValue) : {};
|
||||
|
||||
@@ -124,30 +117,28 @@ const loadSettings = async (): Promise<Settings> => {
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to save settings to AsyncStorage
|
||||
const saveSettings = async (settings: Settings) => {
|
||||
const saveSettings = (settings: Settings) => {
|
||||
const jsonValue = JSON.stringify(settings);
|
||||
await AsyncStorage.setItem("settings", jsonValue);
|
||||
storage.set("settings", jsonValue);
|
||||
};
|
||||
|
||||
// Create an atom to store the settings in memory
|
||||
export const settingsAtom = atom<Settings | null>(null);
|
||||
|
||||
// A hook to manage settings, loading them on initial mount and providing a way to update them
|
||||
export const useSettings = () => {
|
||||
const [settings, setSettings] = useAtom(settingsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings === null) {
|
||||
loadSettings().then(setSettings);
|
||||
const loadedSettings = loadSettings();
|
||||
setSettings(loadedSettings);
|
||||
}
|
||||
}, [settings, setSettings]);
|
||||
|
||||
const updateSettings = async (update: Partial<Settings>) => {
|
||||
const updateSettings = (update: Partial<Settings>) => {
|
||||
if (settings) {
|
||||
const newSettings = { ...settings, ...update };
|
||||
setSettings(newSettings);
|
||||
await saveSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import uuid from "react-native-uuid";
|
||||
import { storage } from "./mmkv";
|
||||
|
||||
export const getOrSetDeviceId = async () => {
|
||||
let deviceId = await AsyncStorage.getItem("deviceId");
|
||||
export const getOrSetDeviceId = () => {
|
||||
let deviceId = storage.getString("deviceId");
|
||||
|
||||
if (!deviceId) {
|
||||
deviceId = uuid.v4() as string;
|
||||
await AsyncStorage.setItem("deviceId", deviceId);
|
||||
storage.set("deviceId", deviceId);
|
||||
}
|
||||
|
||||
return deviceId;
|
||||
};
|
||||
|
||||
export const getDeviceId = async () => {
|
||||
let deviceId = await AsyncStorage.getItem("deviceId");
|
||||
export const getDeviceId = () => {
|
||||
let deviceId = storage.getString("deviceId");
|
||||
|
||||
return deviceId || null;
|
||||
};
|
||||
|
||||
28
utils/log.ts
28
utils/log.ts
@@ -1,5 +1,5 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import { storage } from "./mmkv";
|
||||
|
||||
type LogLevel = "INFO" | "WARN" | "ERROR";
|
||||
|
||||
@@ -10,14 +10,14 @@ interface LogEntry {
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const asyncStorage = createJSONStorage(() => AsyncStorage);
|
||||
const logsAtom = atomWithStorage("logs", [], asyncStorage);
|
||||
const mmkvStorage = createJSONStorage(() => ({
|
||||
getItem: (key: string) => storage.getString(key) || null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
}));
|
||||
const logsAtom = atomWithStorage("logs", [], mmkvStorage);
|
||||
|
||||
export const writeToLog = async (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
data?: any
|
||||
) => {
|
||||
export const writeToLog = (level: LogLevel, message: string, data?: any) => {
|
||||
const newEntry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: level,
|
||||
@@ -25,23 +25,23 @@ export const writeToLog = async (
|
||||
data: data,
|
||||
};
|
||||
|
||||
const currentLogs = await AsyncStorage.getItem("logs");
|
||||
const currentLogs = storage.getString("logs");
|
||||
const logs: LogEntry[] = currentLogs ? JSON.parse(currentLogs) : [];
|
||||
logs.push(newEntry);
|
||||
|
||||
const maxLogs = 100;
|
||||
const recentLogs = logs.slice(Math.max(logs.length - maxLogs, 0));
|
||||
|
||||
await AsyncStorage.setItem("logs", JSON.stringify(recentLogs));
|
||||
storage.set("logs", JSON.stringify(recentLogs));
|
||||
};
|
||||
|
||||
export const readFromLog = async (): Promise<LogEntry[]> => {
|
||||
const logs = await AsyncStorage.getItem("logs");
|
||||
export const readFromLog = (): LogEntry[] => {
|
||||
const logs = storage.getString("logs");
|
||||
return logs ? JSON.parse(logs) : [];
|
||||
};
|
||||
|
||||
export const clearLogs = async () => {
|
||||
await AsyncStorage.removeItem("logs");
|
||||
export const clearLogs = () => {
|
||||
storage.delete("logs");
|
||||
};
|
||||
|
||||
export default logsAtom;
|
||||
|
||||
Reference in New Issue
Block a user