merge develop

This commit is contained in:
sarendsen
2025-02-05 09:44:03 +01:00
172 changed files with 16180 additions and 3607 deletions

View File

@@ -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 () => {

View File

@@ -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>
);
};

View File

@@ -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,

View 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;
}