mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-03 12:38:26 +01:00
merge develop
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {DownloadMethod, useSettings} from "@/utils/atoms/settings";
|
||||
import { getOrSetDeviceId } from "@/utils/device";
|
||||
import { useLog, writeToLog } from "@/utils/log";
|
||||
import {
|
||||
@@ -52,8 +52,9 @@ import useImageStorage from "@/hooks/useImageStorage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import useDownloadHelper from "@/utils/download";
|
||||
import { FileInfo } from "expo-file-system";
|
||||
import * as Haptics from "@/packages/expo-haptics";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import * as Application from "expo-application";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type DownloadedItem = {
|
||||
item: Partial<BaseItemDto>;
|
||||
@@ -73,6 +74,7 @@ const DownloadContext = createContext<ReturnType<
|
||||
function useDownloadProvider() {
|
||||
if (Platform.isTV) return;
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [settings] = useSettings();
|
||||
const router = useRouter();
|
||||
const [api] = useAtom(apiAtom);
|
||||
@@ -83,6 +85,8 @@ function useDownloadProvider() {
|
||||
|
||||
const [processes, setProcesses] = useAtom<JobStatus[]>(processesAtom);
|
||||
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
|
||||
const authHeader = useMemo(() => {
|
||||
return api?.accessToken;
|
||||
}, [api]);
|
||||
@@ -109,7 +113,7 @@ function useDownloadProvider() {
|
||||
const url = settings?.optimizedVersionsServerUrl;
|
||||
|
||||
if (
|
||||
settings?.downloadMethod !== "optimized" ||
|
||||
settings?.downloadMethod !== DownloadMethod.Optimized ||
|
||||
!url ||
|
||||
!deviceId ||
|
||||
!authHeader
|
||||
@@ -142,9 +146,9 @@ function useDownloadProvider() {
|
||||
if (settings.autoDownload) {
|
||||
startDownload(job);
|
||||
} else {
|
||||
toast.info(`${job.item.Name} is ready to be downloaded`, {
|
||||
toast.info(t("home.downloads.toasts.item_is_ready_to_be_downloaded",{item: job.item.Name}), {
|
||||
action: {
|
||||
label: "Go to downloads",
|
||||
label: t("home.downloads.toasts.go_to_downloads"),
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
@@ -169,7 +173,7 @@ function useDownloadProvider() {
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchInterval: 2000,
|
||||
enabled: settings?.downloadMethod === "optimized",
|
||||
enabled: settings?.downloadMethod === DownloadMethod.Optimized,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -227,9 +231,9 @@ function useDownloadProvider() {
|
||||
},
|
||||
});
|
||||
|
||||
toast.info(`Download started for ${process.item.Name}`, {
|
||||
toast.info(t("home.downloads.toasts.download_stated_for_item", {item: process.item.Name}), {
|
||||
action: {
|
||||
label: "Go to downloads",
|
||||
label: t("home.downloads.toasts.go_to_downloads"),
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
@@ -278,10 +282,10 @@ function useDownloadProvider() {
|
||||
process.item,
|
||||
doneHandler.bytesDownloaded
|
||||
);
|
||||
toast.success(`Download completed for ${process.item.Name}`, {
|
||||
toast.success(t("home.downloads.toasts.download_completed_for_item", {item: process.item.Name}), {
|
||||
duration: 3000,
|
||||
action: {
|
||||
label: "Go to downloads",
|
||||
label: t("home.downloads.toasts.go_to_downloads"),
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
@@ -303,7 +307,7 @@ function useDownloadProvider() {
|
||||
if (error.errorCode === 404) {
|
||||
errorMsg = "File not found on server";
|
||||
}
|
||||
toast.error(`Download failed for ${process.item.Name} - ${errorMsg}`);
|
||||
toast.error(t("home.downloads.toasts.download_failed_for_item", {item: process.item.Name, error: errorMsg}));
|
||||
writeToLog("ERROR", `Download failed for ${process.item.Name}`, {
|
||||
error,
|
||||
processDetails: {
|
||||
@@ -360,9 +364,9 @@ function useDownloadProvider() {
|
||||
throw new Error("Failed to start optimization job");
|
||||
}
|
||||
|
||||
toast.success(`Queued ${item.Name} for optimization`, {
|
||||
toast.success(t("home.downloads.toasts.queued_item_for_optimization", {item: item.Name}), {
|
||||
action: {
|
||||
label: "Go to download",
|
||||
label: t("home.downloads.toasts.go_to_downloads"),
|
||||
onClick: () => {
|
||||
router.push("/downloads");
|
||||
toast.dismiss();
|
||||
@@ -380,21 +384,21 @@ function useDownloadProvider() {
|
||||
headers: error.response?.headers,
|
||||
});
|
||||
toast.error(
|
||||
`Failed to start download for ${item.Name}: ${error.message}`
|
||||
t("home.downloads.toasts.failed_to_start_download_for_item", {item: item.Name, message: error.message})
|
||||
);
|
||||
if (error.response) {
|
||||
toast.error(
|
||||
`Server responded with status ${error.response.status}`
|
||||
t("home.downloads.toasts.server_responded_with_status", {statusCode: error.response.status})
|
||||
);
|
||||
} else if (error.request) {
|
||||
toast.error("No response received from server");
|
||||
t("home.downloads.toasts.no_response_received_from_server");
|
||||
} else {
|
||||
toast.error("Error setting up the request");
|
||||
}
|
||||
} else {
|
||||
console.error("Non-Axios error:", error);
|
||||
toast.error(
|
||||
`Failed to start download for ${item.Name}: Unexpected error`
|
||||
t("home.downloads.toasts.failed_to_start_download_for_item_unexpected_error", {item: item.Name})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -410,11 +414,11 @@ function useDownloadProvider() {
|
||||
queryClient.invalidateQueries({ queryKey: ["downloadedItems"] }),
|
||||
])
|
||||
.then(() =>
|
||||
toast.success("All files, folders, and jobs deleted successfully")
|
||||
toast.success(t("home.downloads.toasts.all_files_folders_and_jobs_deleted_successfully"))
|
||||
)
|
||||
.catch((reason) => {
|
||||
console.error("Failed to delete all files, folders, and jobs:", reason);
|
||||
toast.error("An error occurred while deleting files and jobs");
|
||||
toast.error(t("home.downloads.toasts.an_error_occured_while_deleting_files_and_jobs"));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -537,9 +541,7 @@ function useDownloadProvider() {
|
||||
if (i.Id) return deleteFile(i.Id);
|
||||
return;
|
||||
})
|
||||
).then(() =>
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
|
||||
);
|
||||
).then(() => successHapticFeedback());
|
||||
};
|
||||
|
||||
const cleanCacheDirectory = async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "@/augmentations";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import { Api, Jellyfin } from "@jellyfin/sdk";
|
||||
@@ -19,7 +20,13 @@ import React, {
|
||||
import { Platform } from "react-native";
|
||||
import uuid from "react-native-uuid";
|
||||
import { getDeviceName } from "react-native-device-info";
|
||||
import { toast } from "sonner-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import { JellyseerrApi, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||
import {
|
||||
useSplashScreenLoading,
|
||||
useSplashScreenVisible,
|
||||
} from "./SplashScreenProvider";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
@@ -48,6 +55,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
|
||||
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const id = getOrSetDeviceId();
|
||||
@@ -55,7 +64,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.24.0" },
|
||||
clientInfo: { name: "Streamyfin", version: "0.25.0" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
@@ -70,6 +79,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [
|
||||
settings,
|
||||
updateSettings,
|
||||
pluginSettings,
|
||||
setPluginSettings,
|
||||
refreshStreamyfinPluginSettings,
|
||||
] = useSettings();
|
||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||
|
||||
useQuery({
|
||||
queryKey: ["user", api],
|
||||
@@ -92,7 +109,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.24.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.25.0"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
@@ -164,6 +181,14 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
useInterval(pollQuickConnect, isPolling ? 1000 : null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await refreshStreamyfinPluginSettings();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useInterval(refreshStreamyfinPluginSettings, 60 * 5 * 1000); // 5 min
|
||||
|
||||
const discoverServers = async (url: string): Promise<Server[]> => {
|
||||
const servers = await jellyfin?.discovery.getRecommendedServerCandidates(
|
||||
url
|
||||
@@ -226,27 +251,43 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
storage.set("user", JSON.stringify(auth.data.User));
|
||||
setApi(jellyfin.createApi(api?.basePath, auth.data?.AccessToken));
|
||||
storage.set("token", auth.data?.AccessToken);
|
||||
|
||||
const recentPluginSettings = await refreshStreamyfinPluginSettings();
|
||||
if (recentPluginSettings?.jellyseerrServerUrl?.value) {
|
||||
const jellyseerrApi = new JellyseerrApi(
|
||||
recentPluginSettings.jellyseerrServerUrl.value
|
||||
);
|
||||
await jellyseerrApi.test().then((result) => {
|
||||
if (result.isValid && result.requiresPass) {
|
||||
jellyseerrApi.login(username, password).then(setJellyseerrUser);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
switch (error.response?.status) {
|
||||
case 401:
|
||||
throw new Error("Invalid username or password");
|
||||
throw new Error(t("login.invalid_username_or_password"));
|
||||
case 403:
|
||||
throw new Error("User does not have permission to log in");
|
||||
throw new Error(
|
||||
t("login.user_does_not_have_permission_to_log_in")
|
||||
);
|
||||
case 408:
|
||||
throw new Error(
|
||||
"Server is taking too long to respond, try again later"
|
||||
t("login.server_is_taking_too_long_to_respond_try_again_later")
|
||||
);
|
||||
case 429:
|
||||
throw new Error(
|
||||
"Server received too many requests, try again later"
|
||||
t("login.server_received_too_many_requests_try_again_later")
|
||||
);
|
||||
case 500:
|
||||
throw new Error("There is a server error");
|
||||
throw new Error(t("login.there_is_a_server_error"));
|
||||
default:
|
||||
throw new Error(
|
||||
"An unexpected error occurred. Did you enter the server URL correctly?"
|
||||
t(
|
||||
"login.an_unexpected_error_occured_did_you_enter_the_correct_url"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -262,6 +303,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
mutationFn: async () => {
|
||||
storage.delete("token");
|
||||
setUser(null);
|
||||
setPluginSettings(undefined);
|
||||
await clearAllJellyseerData();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Logout failed:", error);
|
||||
@@ -306,11 +349,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
initiateQuickConnect,
|
||||
};
|
||||
|
||||
useProtectedRoute(user, isLoading || isFetching);
|
||||
let isLoadingOrFetching = isLoading || isFetching;
|
||||
useProtectedRoute(user, isLoadingOrFetching);
|
||||
|
||||
// show splash screen until everything loaded
|
||||
useSplashScreenLoading(isLoadingOrFetching);
|
||||
const splashScreenVisible = useSplashScreenVisible();
|
||||
|
||||
return (
|
||||
<JellyfinContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{/* don't render login page when loading and splash screen visible */}
|
||||
{isLoadingOrFetching && splashScreenVisible ? undefined : children}
|
||||
</JellyfinContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +38,6 @@ type PlaySettingsContextType = {
|
||||
setPlayUrl: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
playSessionId?: string | null;
|
||||
setOfflineSettings: (data: PlaybackType) => void;
|
||||
setMusicPlaySettings: (item: BaseItemDto, url: string) => void;
|
||||
};
|
||||
|
||||
const PlaySettingsContext = createContext<PlaySettingsContextType | undefined>(
|
||||
@@ -61,13 +60,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
_setPlaySettings(data);
|
||||
}, []);
|
||||
|
||||
const setMusicPlaySettings = (item: BaseItemDto, url: string) => {
|
||||
setPlaySettings({
|
||||
item: item,
|
||||
});
|
||||
setPlayUrl(url);
|
||||
};
|
||||
|
||||
const setPlaySettings = useCallback(
|
||||
async (
|
||||
dataOrUpdater:
|
||||
@@ -147,7 +139,6 @@ export const PlaySettingsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
setPlaySettings,
|
||||
playUrl,
|
||||
setPlayUrl,
|
||||
setMusicPlaySettings,
|
||||
setOfflineSettings,
|
||||
playSessionId,
|
||||
mediaSource,
|
||||
|
||||
103
providers/SplashScreenProvider.tsx
Normal file
103
providers/SplashScreenProvider.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
} from "react";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
|
||||
type SplashScreenContextValue = {
|
||||
registerLoadingComponent: () => () => void;
|
||||
splashScreenVisible: boolean;
|
||||
};
|
||||
|
||||
const SplashScreenContext = createContext<SplashScreenContextValue | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
// Prevent splash screen from auto-hiding
|
||||
void SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export const SplashScreenProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [splashScreenVisible, setSplashScreenVisible] = useState(true);
|
||||
const loadingComponentsCount = useRef(0);
|
||||
const isHidingRef = useRef(false);
|
||||
|
||||
const hideScreenIfNoLoadingComponents = async () => {
|
||||
if (loadingComponentsCount.current === 0 && !isHidingRef.current) {
|
||||
try {
|
||||
isHidingRef.current = true;
|
||||
await SplashScreen.hideAsync();
|
||||
setSplashScreenVisible(false);
|
||||
} catch (error) {
|
||||
console.warn("Failed to hide splash screen:", error);
|
||||
} finally {
|
||||
isHidingRef.current = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const registerLoadingComponent = () => {
|
||||
loadingComponentsCount.current += 1;
|
||||
|
||||
return () => {
|
||||
loadingComponentsCount.current -= 1;
|
||||
void hideScreenIfNoLoadingComponents();
|
||||
};
|
||||
};
|
||||
|
||||
const contextValue: SplashScreenContextValue = {
|
||||
registerLoadingComponent,
|
||||
splashScreenVisible,
|
||||
};
|
||||
|
||||
return (
|
||||
<SplashScreenContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SplashScreenContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show the Splash Screen until component is ready to be displayed.
|
||||
*
|
||||
* @param isLoading The loading state of the component
|
||||
*
|
||||
* ## Usage
|
||||
* ```
|
||||
* const isLoading = loadSomething()
|
||||
* useSplashScreenLoading(isLoading) // splash screen visible until isLoading is false
|
||||
* ```
|
||||
*/
|
||||
export function useSplashScreenLoading(isLoading: boolean) {
|
||||
const context = useContext(SplashScreenContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useSplashScreenLoading must be used within a SplashScreenProvider"
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
return context.registerLoadingComponent();
|
||||
}
|
||||
}, [isLoading]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visibility of the Splash Screen.
|
||||
* @returns the visibility of the Splash Screen
|
||||
*/
|
||||
export function useSplashScreenVisible() {
|
||||
const context = useContext(SplashScreenContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useSplashScreenVisible must be used within a SplashScreenProvider"
|
||||
);
|
||||
}
|
||||
return context.splashScreenVisible;
|
||||
}
|
||||
Reference in New Issue
Block a user