mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 12:08:37 +01:00
Merge origin/develop into refactor-chromecast
Bring 323 commits of develop (incl. the Expo SDK 56 / TV-branch work) into the chromecast refactor. Conflict resolutions: - chapters: take develop's reviewed version (ChapterList/ChapterTicks/ chapters.ts/test) — adds chapterNameAt, markers API, themed Colors. - auto-skip: keep chromecast's unified useSegmentSkipper for the phone player; restore develop's useCreditSkipper/useIntroSkipper (deleted on chromecast) so develop's Controls.tv.tsx compiles. TV->useSegmentSkipper migration left as follow-up. - en.json: union the two player blocks (kept chromecast casting keys + develop's subtitle/playback keys). - TechnicalInfoOverlay/PlatformDropdown: take develop's TV-safe versions (kept chromecast's disabled-prop branch, aliased to avoid shadowing the @expo/ui disabled modifier). - SDK 56 fixes: expo-router Router -> ImperativeRouter in cast components; ChapterTicks markers API in CastPlayerProgressBar. - restore utils/profiles/chromecast* (deleted on chromecast, still used by PlayButton). Typecheck passes; bun.lock regenerated against merged package.json.
This commit is contained in:
221
providers/InactivityProvider.tsx
Normal file
221
providers/InactivityProvider.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { AppState, type AppStateStatus, Platform } from "react-native";
|
||||
import { useJellyfin } from "@/providers/JellyfinProvider";
|
||||
import { InactivityTimeout, useSettings } from "@/utils/atoms/settings";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
const INACTIVITY_LAST_ACTIVITY_KEY = "INACTIVITY_LAST_ACTIVITY";
|
||||
|
||||
interface InactivityContextValue {
|
||||
resetInactivityTimer: () => void;
|
||||
pauseInactivityTimer: () => void;
|
||||
resumeInactivityTimer: () => void;
|
||||
}
|
||||
|
||||
const InactivityContext = createContext<InactivityContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
* TV-only provider that tracks user inactivity and auto-logs out
|
||||
* when the configured timeout is exceeded.
|
||||
*
|
||||
* Features:
|
||||
* - Tracks last activity timestamp (persisted to MMKV)
|
||||
* - Resets timer on any focus change (via resetInactivityTimer)
|
||||
* - Pauses timer during video playback (via pauseInactivityTimer/resumeInactivityTimer)
|
||||
* - Handles app backgrounding: logs out immediately if timeout exceeded while away
|
||||
* - No-op on mobile platforms
|
||||
*/
|
||||
export const InactivityProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { settings } = useSettings();
|
||||
const { logout } = useJellyfin();
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||
const isPausedRef = useRef(false);
|
||||
|
||||
const timeoutMs = settings.inactivityTimeout ?? InactivityTimeout.Disabled;
|
||||
const isEnabled = Platform.isTV && timeoutMs > 0;
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateLastActivity = useCallback(() => {
|
||||
if (!isEnabled) return;
|
||||
storage.set(INACTIVITY_LAST_ACTIVITY_KEY, Date.now());
|
||||
}, [isEnabled]);
|
||||
|
||||
const getLastActivity = useCallback((): number => {
|
||||
return storage.getNumber(INACTIVITY_LAST_ACTIVITY_KEY) ?? Date.now();
|
||||
}, []);
|
||||
|
||||
const startTimer = useCallback(
|
||||
(remainingMs?: number) => {
|
||||
if (!isEnabled || isPausedRef.current) return;
|
||||
|
||||
clearTimer();
|
||||
|
||||
const delay = remainingMs ?? timeoutMs;
|
||||
timerRef.current = setTimeout(() => {
|
||||
logout();
|
||||
storage.remove(INACTIVITY_LAST_ACTIVITY_KEY);
|
||||
}, delay);
|
||||
},
|
||||
[isEnabled, timeoutMs, clearTimer, logout],
|
||||
);
|
||||
|
||||
const resetInactivityTimer = useCallback(() => {
|
||||
if (!isEnabled || isPausedRef.current) return;
|
||||
|
||||
updateLastActivity();
|
||||
startTimer();
|
||||
}, [isEnabled, updateLastActivity, startTimer]);
|
||||
|
||||
const pauseInactivityTimer = useCallback(() => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
isPausedRef.current = true;
|
||||
clearTimer();
|
||||
// Update last activity so when we resume, we start fresh
|
||||
updateLastActivity();
|
||||
}, [isEnabled, clearTimer, updateLastActivity]);
|
||||
|
||||
const resumeInactivityTimer = useCallback(() => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
isPausedRef.current = false;
|
||||
updateLastActivity();
|
||||
startTimer();
|
||||
}, [isEnabled, updateLastActivity, startTimer]);
|
||||
|
||||
// Handle app state changes (background/foreground)
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
const wasBackground =
|
||||
appStateRef.current === "background" ||
|
||||
appStateRef.current === "inactive";
|
||||
const isNowActive = nextAppState === "active";
|
||||
|
||||
if (wasBackground && isNowActive) {
|
||||
// App returned to foreground
|
||||
// If paused (e.g., video playing), don't check timeout
|
||||
if (isPausedRef.current) {
|
||||
appStateRef.current = nextAppState;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if timeout exceeded
|
||||
const lastActivity = getLastActivity();
|
||||
const elapsed = Date.now() - lastActivity;
|
||||
|
||||
if (elapsed >= timeoutMs) {
|
||||
// Timeout exceeded while backgrounded - logout immediately
|
||||
logout();
|
||||
storage.remove(INACTIVITY_LAST_ACTIVITY_KEY);
|
||||
} else {
|
||||
// Restart timer with remaining time
|
||||
const remainingMs = timeoutMs - elapsed;
|
||||
startTimer(remainingMs);
|
||||
}
|
||||
} else if (nextAppState === "background" || nextAppState === "inactive") {
|
||||
// App going to background - clear timer (time continues via timestamp)
|
||||
clearTimer();
|
||||
}
|
||||
|
||||
appStateRef.current = nextAppState;
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener(
|
||||
"change",
|
||||
handleAppStateChange,
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]);
|
||||
|
||||
// Initialize timer when enabled or timeout changes
|
||||
useEffect(() => {
|
||||
if (!isEnabled) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't start timer if paused
|
||||
if (isPausedRef.current) return;
|
||||
|
||||
// Check if we should logout based on last activity
|
||||
const lastActivity = getLastActivity();
|
||||
const elapsed = Date.now() - lastActivity;
|
||||
|
||||
if (elapsed >= timeoutMs) {
|
||||
// Already timed out - logout
|
||||
logout();
|
||||
storage.remove(INACTIVITY_LAST_ACTIVITY_KEY);
|
||||
} else {
|
||||
// Start timer with remaining time
|
||||
const remainingMs = timeoutMs - elapsed;
|
||||
startTimer(remainingMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [isEnabled, timeoutMs, getLastActivity, startTimer, clearTimer, logout]);
|
||||
|
||||
// Reset activity on initial mount when enabled
|
||||
useEffect(() => {
|
||||
if (isEnabled && !isPausedRef.current) {
|
||||
updateLastActivity();
|
||||
startTimer();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const contextValue: InactivityContextValue = {
|
||||
resetInactivityTimer,
|
||||
pauseInactivityTimer,
|
||||
resumeInactivityTimer,
|
||||
};
|
||||
|
||||
return (
|
||||
<InactivityContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</InactivityContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access the inactivity timer controls.
|
||||
* Returns no-op functions if not within the provider (safe on mobile).
|
||||
*/
|
||||
export const useInactivity = (): InactivityContextValue => {
|
||||
const context = useContext(InactivityContext);
|
||||
|
||||
// Return no-ops if not within provider (e.g., on mobile)
|
||||
if (!context) {
|
||||
return {
|
||||
resetInactivityTimer: () => {},
|
||||
pauseInactivityTimer: () => {},
|
||||
resumeInactivityTimer: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import "@/augmentations";
|
||||
import { type Api, Jellyfin } from "@jellyfin/sdk";
|
||||
import type { UserDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getUserApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { useSegments } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppState, Platform } from "react-native";
|
||||
import { getDeviceName } from "react-native-device-info";
|
||||
import { getDeviceNameSync } from "react-native-device-info";
|
||||
import uuid from "react-native-uuid";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useInterval } from "@/hooks/useInterval";
|
||||
@@ -29,6 +29,7 @@ import { writeErrorLog, writeInfoLog } from "@/utils/log";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
import {
|
||||
type AccountSecurityType,
|
||||
addAccountToServer,
|
||||
addServerToList,
|
||||
deleteAccountCredential,
|
||||
getAccountCredential,
|
||||
@@ -38,14 +39,57 @@ import {
|
||||
updateAccountToken,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
|
||||
|
||||
interface Server {
|
||||
address: string;
|
||||
}
|
||||
|
||||
export const apiAtom = atom<Api | null>(null);
|
||||
export const userAtom = atom<UserDto | null>(null);
|
||||
const initialApi = (() => {
|
||||
try {
|
||||
const token = storage.getString("token") || null;
|
||||
const serverUrl = storage.getString("serverUrl") || null;
|
||||
if (serverUrl && token) {
|
||||
const id = getOrSetDeviceId();
|
||||
const deviceName = getDeviceNameSync();
|
||||
const jellyfinInstance = new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
},
|
||||
});
|
||||
return jellyfinInstance.createApi(serverUrl, token);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize API synchronously:", e);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const initialUser = (() => {
|
||||
try {
|
||||
// Only return a stored user if we also have a token. Otherwise the
|
||||
// user atom would be populated while the api atom is null (e.g. after
|
||||
// a logout that left stale user JSON in storage), which causes
|
||||
// useProtectedRoute to keep us inside the (auth) group instead of
|
||||
// redirecting to /login.
|
||||
const token = storage.getString("token");
|
||||
if (!token) return null;
|
||||
const userStr = storage.getString("user");
|
||||
if (userStr) {
|
||||
return JSON.parse(userStr) as UserDto;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse initial user synchronously:", e);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
export const apiAtom = atom<Api | null>(initialApi);
|
||||
export const userAtom = atom<UserDto | null>(initialUser);
|
||||
export const wsAtom = atom<WebSocket | null>(null);
|
||||
export const cacheVersionAtom = atom<number>(0);
|
||||
|
||||
interface LoginOptions {
|
||||
saveAccount?: boolean;
|
||||
@@ -65,6 +109,7 @@ interface JellyfinContextValue {
|
||||
) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
initiateQuickConnect: () => Promise<string | undefined>;
|
||||
stopQuickConnectPolling: () => void;
|
||||
loginWithSavedCredential: (
|
||||
serverUrl: string,
|
||||
userId: string,
|
||||
@@ -85,42 +130,46 @@ const JellyfinContext = createContext<JellyfinContextValue | undefined>(
|
||||
export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [jellyfin, setJellyfin] = useState<Jellyfin | undefined>(undefined);
|
||||
const [deviceId, setDeviceId] = useState<string | undefined>(undefined);
|
||||
const [jellyfin] = useState<Jellyfin | undefined>(() => {
|
||||
try {
|
||||
const id = getOrSetDeviceId();
|
||||
const deviceName = getDeviceNameSync();
|
||||
return new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.54.1" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize Jellyfin synchronously in state:", e);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
const [deviceId] = useState<string | undefined>(() => {
|
||||
try {
|
||||
return getOrSetDeviceId();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const id = getOrSetDeviceId();
|
||||
const deviceName = await getDeviceName();
|
||||
setJellyfin(
|
||||
() =>
|
||||
new Jellyfin({
|
||||
clientInfo: { name: "Streamyfin", version: "0.52.0" },
|
||||
deviceInfo: {
|
||||
name: deviceName,
|
||||
id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
setDeviceId(id);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const [api, setApi] = useAtom(apiAtom);
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const { setPluginSettings, refreshStreamyfinPluginSettings } = useSettings();
|
||||
const { clearAllJellyseerData, setJellyseerrUser } = useJellyseerr();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const headers = useMemo(() => {
|
||||
if (!deviceId) return {};
|
||||
return {
|
||||
authorization: `MediaBrowser Client="Streamyfin", Device=${
|
||||
Platform.OS === "android" ? "Android" : "iOS"
|
||||
}, DeviceId="${deviceId}", Version="0.52.0"`,
|
||||
}, DeviceId="${deviceId}", Version="0.54.1"`,
|
||||
};
|
||||
}, [deviceId]);
|
||||
|
||||
@@ -146,6 +195,11 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
}, [api, deviceId, headers]);
|
||||
|
||||
const stopQuickConnectPolling = useCallback(() => {
|
||||
setIsPolling(false);
|
||||
setSecret(null);
|
||||
}, []);
|
||||
|
||||
const pollQuickConnect = useCallback(async () => {
|
||||
if (!api || !secret || !jellyfin) return;
|
||||
|
||||
@@ -178,10 +232,15 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError && error.response?.status === 400) {
|
||||
setIsPolling(false);
|
||||
setSecret(null);
|
||||
throw new Error("The code has expired. Please try again.");
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 400 || error.response?.status === 404) {
|
||||
setIsPolling(false);
|
||||
setSecret(null);
|
||||
if (error.response?.status === 400) {
|
||||
throw new Error("The code has expired. Please try again.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
console.error("Error polling Quick Connect:", error);
|
||||
throw error;
|
||||
@@ -219,6 +278,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const setServerMutation = useMutation({
|
||||
mutationFn: async (server: Server) => {
|
||||
clearTVDiscoverySafely();
|
||||
const apiInstance = jellyfin?.createApi(server.address);
|
||||
|
||||
if (!apiInstance?.basePath) throw new Error("Failed to connect");
|
||||
@@ -237,6 +297,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const removeServerMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
clearTVDiscoverySafely();
|
||||
storage.remove("serverUrl");
|
||||
setApi(null);
|
||||
},
|
||||
@@ -286,6 +347,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
savedAt: Date.now(),
|
||||
securityType,
|
||||
pinHash,
|
||||
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,7 +400,8 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api
|
||||
// Fire-and-forget: don't block logout on server cleanup
|
||||
api
|
||||
?.delete(`/Streamyfin/device/${deviceId}`)
|
||||
.then((_r) => writeInfoLog("Deleted expo push token for device"))
|
||||
.catch((_e) =>
|
||||
@@ -346,10 +409,17 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
);
|
||||
|
||||
storage.remove("token");
|
||||
storage.remove("user");
|
||||
clearTVDiscoverySafely();
|
||||
setUser(null);
|
||||
setApi(null);
|
||||
setPluginSettings(undefined);
|
||||
await clearAllJellyseerData();
|
||||
|
||||
// Clear React Query cache to prevent data from previous account lingering
|
||||
queryClient.clear();
|
||||
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
||||
|
||||
// Note: We keep saved credentials for quick switching back
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -382,6 +452,10 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
try {
|
||||
const response = await getUserApi(apiInstance).getCurrentUser();
|
||||
|
||||
// Clear React Query cache to prevent data from previous account lingering
|
||||
queryClient.clear();
|
||||
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
||||
|
||||
// Token is valid, update state
|
||||
setApi(apiInstance);
|
||||
setUser(response.data);
|
||||
@@ -389,17 +463,47 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
storage.set("token", credential.token);
|
||||
storage.set("user", JSON.stringify(response.data));
|
||||
|
||||
// Update account info (in case user changed their avatar)
|
||||
if (response.data.PrimaryImageTag !== credential.primaryImageTag) {
|
||||
addAccountToServer(serverUrl, credential.serverName, {
|
||||
userId: credential.userId,
|
||||
username: credential.username,
|
||||
securityType: credential.securityType,
|
||||
savedAt: credential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh plugin settings
|
||||
await refreshStreamyfinPluginSettings();
|
||||
} catch (error) {
|
||||
// Token is invalid/expired - remove it
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 401 || error.response?.status === 403)
|
||||
) {
|
||||
await deleteAccountCredential(serverUrl, userId);
|
||||
throw new Error(t("server.session_expired"));
|
||||
// Check for axios error
|
||||
if (axios.isAxiosError(error)) {
|
||||
// Token is invalid/expired - remove it
|
||||
if (
|
||||
error.response?.status === 401 ||
|
||||
error.response?.status === 403
|
||||
) {
|
||||
await deleteAccountCredential(serverUrl, userId);
|
||||
throw new Error(t("server.session_expired"));
|
||||
}
|
||||
|
||||
// Network error - server not reachable (no response means server didn't respond)
|
||||
if (!error.response) {
|
||||
throw new Error(t("home.server_unreachable"));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for network error by message pattern (fallback detection)
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.toLowerCase().includes("network") ||
|
||||
error.message.toLowerCase().includes("econnrefused") ||
|
||||
error.message.toLowerCase().includes("timeout"))
|
||||
) {
|
||||
throw new Error(t("home.server_unreachable"));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -430,17 +534,22 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const auth = await apiInstance.authenticateUserByName(username, password);
|
||||
|
||||
if (auth.data.AccessToken && auth.data.User) {
|
||||
// Clear React Query cache to prevent data from previous account lingering
|
||||
queryClient.clear();
|
||||
storage.remove("REACT_QUERY_OFFLINE_CACHE");
|
||||
|
||||
setUser(auth.data.User);
|
||||
storage.set("user", JSON.stringify(auth.data.User));
|
||||
setApi(jellyfin.createApi(serverUrl, auth.data.AccessToken));
|
||||
storage.set("serverUrl", serverUrl);
|
||||
storage.set("token", auth.data.AccessToken);
|
||||
|
||||
// Update the saved credential with new token
|
||||
// Update the saved credential with new token and image tag
|
||||
await updateAccountToken(
|
||||
serverUrl,
|
||||
auth.data.User.Id || "",
|
||||
auth.data.AccessToken,
|
||||
auth.data.User.PrimaryImageTag ?? undefined,
|
||||
);
|
||||
|
||||
// Refresh plugin settings
|
||||
@@ -472,6 +581,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
(newUrl: string) => {
|
||||
if (!jellyfin || !api?.accessToken) return;
|
||||
|
||||
clearTVDiscoverySafely();
|
||||
const newApi = jellyfin.createApi(newUrl, api.accessToken);
|
||||
setApi(newApi);
|
||||
// Note: We don't update storage.set("serverUrl") here
|
||||
@@ -527,6 +637,19 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
username: storedUser.Name,
|
||||
savedAt: Date.now(),
|
||||
securityType: "none",
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
} else if (
|
||||
response.data.PrimaryImageTag !==
|
||||
existingCredential.primaryImageTag
|
||||
) {
|
||||
// Update image tag if it has changed
|
||||
addAccountToServer(serverUrl, existingCredential.serverName, {
|
||||
userId: existingCredential.userId,
|
||||
username: existingCredential.username,
|
||||
securityType: existingCredential.securityType,
|
||||
savedAt: existingCredential.savedAt,
|
||||
primaryImageTag: response.data.PrimaryImageTag ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -549,6 +672,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
|
||||
loginMutation.mutateAsync({ username, password, serverName, options }),
|
||||
logout: () => logoutMutation.mutateAsync(),
|
||||
initiateQuickConnect,
|
||||
stopQuickConnectPolling,
|
||||
loginWithSavedCredential: (serverUrl, userId) =>
|
||||
loginWithSavedCredentialMutation.mutateAsync({ serverUrl, userId }),
|
||||
loginWithPassword: (serverUrl, username, password) =>
|
||||
@@ -588,10 +712,11 @@ function useProtectedRoute(user: UserDto | null, loaded = false) {
|
||||
if (loaded === false) return;
|
||||
|
||||
const inAuthGroup = segments.length > 1 && segments[0] === "(auth)";
|
||||
const isTopShelfLaunchRoute = segments[0] === "topshelf";
|
||||
|
||||
if (!user?.Id && inAuthGroup) {
|
||||
router.replace("/login");
|
||||
} else if (user?.Id && !inAuthGroup) {
|
||||
} else if (user?.Id && !inAuthGroup && !isTopShelfLaunchRoute) {
|
||||
router.replace("/(auth)/(tabs)/(home)/");
|
||||
}
|
||||
}, [user, segments, loaded]);
|
||||
|
||||
@@ -15,12 +15,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import TrackPlayer, {
|
||||
Capability,
|
||||
type Progress,
|
||||
RepeatMode as TPRepeatMode,
|
||||
type Track,
|
||||
} from "react-native-track-player";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
downloadTrack,
|
||||
getLocalPath,
|
||||
@@ -38,6 +33,22 @@ import {
|
||||
useRegisterPlaybackController,
|
||||
} from "@/utils/playback/playbackController";
|
||||
|
||||
// Conditionally import TrackPlayer only on non-TV platforms
|
||||
// This prevents the native module from being loaded on TV where it doesn't exist
|
||||
const TrackPlayer = Platform.isTV
|
||||
? null
|
||||
: require("react-native-track-player").default;
|
||||
|
||||
const TrackPlayerModule = Platform.isTV
|
||||
? null
|
||||
: require("react-native-track-player");
|
||||
|
||||
// Extract types and enums from the module (only available on non-TV)
|
||||
const Capability = TrackPlayerModule?.Capability;
|
||||
const TPRepeatMode = TrackPlayerModule?.RepeatMode;
|
||||
type Track = NonNullable<typeof TrackPlayerModule>["Track"];
|
||||
type Progress = NonNullable<typeof TrackPlayerModule>["Progress"];
|
||||
|
||||
// Storage keys
|
||||
const STORAGE_KEYS = {
|
||||
QUEUE: "music_player_queue",
|
||||
@@ -120,6 +131,28 @@ interface MusicPlayerContextType extends MusicPlayerState {
|
||||
triggerLookahead: () => void;
|
||||
}
|
||||
|
||||
const defaultState: MusicPlayerState = {
|
||||
currentTrack: null,
|
||||
queue: [],
|
||||
originalQueue: [],
|
||||
queueIndex: 0,
|
||||
isPlaying: false,
|
||||
isLoading: false,
|
||||
loadingTrackId: null,
|
||||
progress: 0,
|
||||
duration: 0,
|
||||
streamUrl: null,
|
||||
playSessionId: null,
|
||||
repeatMode: "off",
|
||||
shuffleEnabled: false,
|
||||
mediaSource: null,
|
||||
isTranscoding: false,
|
||||
trackMediaInfoMap: {},
|
||||
};
|
||||
|
||||
// No-op function for TV stub
|
||||
const noop = () => {};
|
||||
|
||||
const MusicPlayerContext = createContext<MusicPlayerContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -136,6 +169,48 @@ interface MusicPlayerProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Stub provider for tvOS - music playback is not supported
|
||||
const TVMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const value: MusicPlayerContextType = {
|
||||
...defaultState,
|
||||
playTrack: noop,
|
||||
playQueue: noop,
|
||||
playAlbum: noop,
|
||||
playPlaylist: noop,
|
||||
pause: noop,
|
||||
resume: noop,
|
||||
togglePlayPause: noop,
|
||||
next: noop,
|
||||
previous: noop,
|
||||
seek: noop,
|
||||
stop: noop,
|
||||
addToQueue: noop,
|
||||
playNext: noop,
|
||||
removeFromQueue: noop,
|
||||
moveInQueue: noop,
|
||||
reorderQueue: noop,
|
||||
clearQueue: noop,
|
||||
jumpToIndex: noop,
|
||||
setRepeatMode: noop,
|
||||
toggleShuffle: noop,
|
||||
setProgress: noop,
|
||||
setDuration: noop,
|
||||
setIsPlaying: noop,
|
||||
reportProgress: noop,
|
||||
onTrackEnd: noop,
|
||||
syncFromTrackPlayer: noop,
|
||||
triggerLookahead: noop,
|
||||
};
|
||||
|
||||
return (
|
||||
<MusicPlayerContext.Provider value={value}>
|
||||
{children}
|
||||
</MusicPlayerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Persistence helpers
|
||||
const saveQueueToStorage = (queue: BaseItemDto[], queueIndex: number) => {
|
||||
try {
|
||||
@@ -276,7 +351,8 @@ const itemToTrack = (
|
||||
};
|
||||
};
|
||||
|
||||
export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
// Full implementation for non-TV platforms
|
||||
const MobileMusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
@@ -310,6 +386,8 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
// Setup TrackPlayer and AudioStorage
|
||||
useEffect(() => {
|
||||
if (!TrackPlayer) return;
|
||||
|
||||
const setupPlayer = async () => {
|
||||
if (playerSetupRef.current) return;
|
||||
|
||||
@@ -358,19 +436,21 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
// Sync repeat mode to TrackPlayer
|
||||
useEffect(() => {
|
||||
if (!TrackPlayer) return;
|
||||
|
||||
const syncRepeatMode = async () => {
|
||||
if (!playerSetupRef.current) return;
|
||||
|
||||
let tpRepeatMode: TPRepeatMode;
|
||||
let tpRepeatMode: typeof TPRepeatMode;
|
||||
switch (state.repeatMode) {
|
||||
case "one":
|
||||
tpRepeatMode = TPRepeatMode.Track;
|
||||
tpRepeatMode = TPRepeatMode?.Track;
|
||||
break;
|
||||
case "all":
|
||||
tpRepeatMode = TPRepeatMode.Queue;
|
||||
tpRepeatMode = TPRepeatMode?.Queue;
|
||||
break;
|
||||
default:
|
||||
tpRepeatMode = TPRepeatMode.Off;
|
||||
tpRepeatMode = TPRepeatMode?.Off;
|
||||
}
|
||||
await TrackPlayer.setRepeatMode(tpRepeatMode);
|
||||
};
|
||||
@@ -557,14 +637,13 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
// Load remaining tracks in the background without blocking playback
|
||||
const loadRemainingTracksInBackground = useCallback(
|
||||
async (queue: BaseItemDto[], startIndex: number, preferLocal: boolean) => {
|
||||
if (!api || !user?.Id) return;
|
||||
if (!api || !user?.Id || !TrackPlayer) return;
|
||||
|
||||
const mediaInfoMap: Record<string, TrackMediaInfo> = {};
|
||||
const failedItemIds: string[] = []; // Track items that failed to prepare
|
||||
|
||||
// Process tracks BEFORE the start index (insert at position 0, pushing current track forward)
|
||||
const beforeTracks: Track[] = [];
|
||||
const beforeSuccessIds: string[] = []; // Track successful IDs to maintain order
|
||||
for (let i = 0; i < startIndex; i++) {
|
||||
const item = queue[i];
|
||||
if (!item.Id) continue;
|
||||
@@ -572,7 +651,6 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
const prepared = await prepareTrack(item, preferLocal);
|
||||
if (prepared) {
|
||||
beforeTracks.push(prepared.track);
|
||||
beforeSuccessIds.push(item.Id);
|
||||
if (prepared.mediaInfo) {
|
||||
mediaInfoMap[item.Id] = prepared.mediaInfo;
|
||||
}
|
||||
@@ -645,7 +723,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
const loadAndPlayQueue = useCallback(
|
||||
async (queue: BaseItemDto[], startIndex: number) => {
|
||||
if (!api || !user?.Id || queue.length === 0) return;
|
||||
if (!api || !user?.Id || queue.length === 0 || !TrackPlayer) return;
|
||||
|
||||
const preferLocal = settings?.preferLocalAudio ?? true;
|
||||
|
||||
@@ -860,11 +938,13 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
if (!TrackPlayer) return;
|
||||
await TrackPlayer.pause();
|
||||
setState((prev) => ({ ...prev, isPlaying: false }));
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(async () => {
|
||||
if (!TrackPlayer) return;
|
||||
if (!state.streamUrl && state.currentTrack && api && user?.Id) {
|
||||
// Need to load the track first (e.g., after app restart)
|
||||
const result = await getAudioStreamUrl(
|
||||
@@ -909,6 +989,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
}, [state.isPlaying, pause, resume]);
|
||||
|
||||
const next = useCallback(async () => {
|
||||
if (!TrackPlayer) return;
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
const queueLength = (await TrackPlayer.getQueue()).length;
|
||||
|
||||
@@ -968,6 +1049,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
]);
|
||||
|
||||
const previous = useCallback(async () => {
|
||||
if (!TrackPlayer) return;
|
||||
const position = await TrackPlayer.getProgress().then(
|
||||
(p: Progress) => p.position,
|
||||
);
|
||||
@@ -1037,11 +1119,13 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
]);
|
||||
|
||||
const seek = useCallback(async (position: number) => {
|
||||
if (!TrackPlayer) return;
|
||||
await TrackPlayer.seekTo(position);
|
||||
setState((prev) => ({ ...prev, progress: position }));
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (!TrackPlayer) return;
|
||||
if (state.currentTrack && state.playSessionId) {
|
||||
reportPlaybackStopped(
|
||||
state.currentTrack,
|
||||
@@ -1091,7 +1175,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
// Queue management
|
||||
const addToQueue = useCallback(
|
||||
async (tracks: BaseItemDto | BaseItemDto[]) => {
|
||||
if (!api || !user?.Id) return;
|
||||
if (!api || !user?.Id || !TrackPlayer) return;
|
||||
|
||||
const tracksArray = Array.isArray(tracks) ? tracks : [tracks];
|
||||
const preferLocal = settings?.preferLocalAudio ?? true;
|
||||
@@ -1124,7 +1208,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
const playNext = useCallback(
|
||||
async (tracks: BaseItemDto | BaseItemDto[]) => {
|
||||
if (!api || !user?.Id) return;
|
||||
if (!api || !user?.Id || !TrackPlayer) return;
|
||||
|
||||
const tracksArray = Array.isArray(tracks) ? tracks : [tracks];
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
@@ -1172,6 +1256,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
);
|
||||
|
||||
const removeFromQueue = useCallback(async (index: number) => {
|
||||
if (!TrackPlayer) return;
|
||||
const queueLength = (await TrackPlayer.getQueue()).length;
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
|
||||
@@ -1205,6 +1290,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
const moveInQueue = useCallback(
|
||||
async (fromIndex: number, toIndex: number) => {
|
||||
if (!TrackPlayer) return;
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
if (
|
||||
fromIndex < 0 ||
|
||||
@@ -1245,6 +1331,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
// Reorder queue with a new array (used by drag-to-reorder UI)
|
||||
const reorderQueue = useCallback(
|
||||
async (newQueue: BaseItemDto[]) => {
|
||||
if (!TrackPlayer) return;
|
||||
// Find where the current track ended up in the new order
|
||||
const currentTrackId = state.currentTrack?.Id;
|
||||
const newIndex = currentTrackId
|
||||
@@ -1257,7 +1344,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
// Create a map of trackId -> current TrackPlayer index
|
||||
const currentPositions = new Map<string, number>();
|
||||
tpQueue.forEach((track, idx) => {
|
||||
tpQueue.forEach((track: Track, idx: number) => {
|
||||
currentPositions.set(track.id, idx);
|
||||
});
|
||||
|
||||
@@ -1300,6 +1387,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
);
|
||||
|
||||
const clearQueue = useCallback(async () => {
|
||||
if (!TrackPlayer) return;
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
|
||||
@@ -1329,6 +1417,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
const jumpToIndex = useCallback(
|
||||
async (index: number) => {
|
||||
if (!TrackPlayer) return;
|
||||
if (
|
||||
index < 0 ||
|
||||
index >= state.queue.length ||
|
||||
@@ -1464,6 +1553,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
// Sync state from TrackPlayer (called when active track changes)
|
||||
// Uses ID-based lookup instead of index to handle queue mismatches
|
||||
const syncFromTrackPlayer = useCallback(async () => {
|
||||
if (!TrackPlayer) return;
|
||||
const activeTrack = await TrackPlayer.getActiveTrack();
|
||||
if (!activeTrack?.id) return;
|
||||
|
||||
@@ -1480,6 +1570,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
// Called by playback engine when track ends
|
||||
const onTrackEnd = useCallback(() => {
|
||||
if (!TrackPlayer) return;
|
||||
if (state.repeatMode === "one") {
|
||||
TrackPlayer.seekTo(0);
|
||||
TrackPlayer.play();
|
||||
@@ -1489,6 +1580,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
|
||||
// Look-ahead cache: pre-cache upcoming N tracks (excludes current track to avoid bandwidth competition)
|
||||
const triggerLookahead = useCallback(async () => {
|
||||
if (!TrackPlayer) return;
|
||||
// Check if caching is enabled in settings
|
||||
if (settings?.audioLookaheadEnabled === false) return;
|
||||
if (!api || !user?.Id) return;
|
||||
@@ -1639,3 +1731,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
||||
</MusicPlayerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Export the appropriate provider based on platform
|
||||
export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> =
|
||||
Platform.isTV ? TVMusicPlayerProvider : MobileMusicPlayerProvider;
|
||||
|
||||
@@ -35,8 +35,8 @@ async function checkApiReachable(basePath?: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
export function NetworkStatusProvider({ children }: { children: ReactNode }) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [serverConnected, setServerConnected] = useState<boolean | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(true);
|
||||
const [serverConnected, setServerConnected] = useState<boolean | null>(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Platform } from "react-native";
|
||||
import type { Bitrate } from "@/components/BitrateSelector";
|
||||
import { settingsAtom } from "@/utils/atoms/settings";
|
||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
||||
import { generateDeviceProfile } from "@/utils/profiles/native";
|
||||
import { generateDeviceProfile } from "../utils/profiles/native";
|
||||
import { apiAtom, userAtom } from "./JellyfinProvider";
|
||||
|
||||
export type PlaybackType = {
|
||||
|
||||
@@ -34,13 +34,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
||||
const { switchServerUrl } = useJellyfin();
|
||||
const { ssid, permissionStatus } = useWifiSSID();
|
||||
|
||||
console.log(
|
||||
"[ServerUrlProvider] ssid:",
|
||||
ssid,
|
||||
"permissionStatus:",
|
||||
permissionStatus,
|
||||
);
|
||||
|
||||
const [isUsingLocalUrl, setIsUsingLocalUrl] = useState(false);
|
||||
const [effectiveServerUrl, setEffectiveServerUrl] = useState<string | null>(
|
||||
null,
|
||||
@@ -76,13 +69,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
||||
|
||||
const targetUrl = shouldUseLocal ? config!.localUrl : remoteUrl;
|
||||
|
||||
console.log("[ServerUrlProvider] evaluateAndSwitchUrl:", {
|
||||
ssid,
|
||||
shouldUseLocal,
|
||||
targetUrl,
|
||||
config,
|
||||
});
|
||||
|
||||
switchServerUrl(targetUrl);
|
||||
setIsUsingLocalUrl(shouldUseLocal);
|
||||
setEffectiveServerUrl(targetUrl);
|
||||
@@ -90,7 +76,6 @@ export function ServerUrlProvider({ children }: Props): React.ReactElement {
|
||||
|
||||
// Manual refresh function for when config changes
|
||||
const refreshUrlState = useCallback(() => {
|
||||
console.log("[ServerUrlProvider] refreshUrlState called");
|
||||
evaluateAndSwitchUrl();
|
||||
}, [evaluateAndSwitchUrl]);
|
||||
|
||||
|
||||
@@ -12,10 +12,23 @@ import {
|
||||
} from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
import { useRemoteControl } from "@/hooks/useRemoteControl";
|
||||
import { apiAtom, getOrSetDeviceId } from "@/providers/JellyfinProvider";
|
||||
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
|
||||
|
||||
// Query keys that depend on the set of library items and should be refreshed
|
||||
// when the server reports that the library changed (items added/removed/updated).
|
||||
const LIBRARY_CHANGE_QUERY_KEYS = [
|
||||
["home"],
|
||||
["library-items"],
|
||||
["nextUp-all"],
|
||||
["nextUp"],
|
||||
["resumeItems"],
|
||||
["seasons"],
|
||||
["episodes"],
|
||||
] as const;
|
||||
|
||||
interface WebSocketMessage {
|
||||
MessageType: string;
|
||||
Data: any;
|
||||
@@ -45,10 +58,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
// Route Jellyfin remote-control messages to the active player.
|
||||
useRemoteControl(lastMessage);
|
||||
const router = useRouter();
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const deviceId = useMemo(() => {
|
||||
return getOrSetDeviceId();
|
||||
}, []);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const connectWebSocket = useCallback(() => {
|
||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||
@@ -69,7 +86,6 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
const reconnectDelay = 10000;
|
||||
|
||||
newWebSocket.onopen = () => {
|
||||
console.log("WebSocket connection opened");
|
||||
setIsConnected(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
keepAliveInterval = setInterval(() => {
|
||||
@@ -115,18 +131,57 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
};
|
||||
}, [api, deviceId, isNetworkConnected]);
|
||||
|
||||
const handleLibraryChanged = useCallback(
|
||||
(data: any) => {
|
||||
// Jellyfin sends LibraryChanged when a scan adds/updates/removes items.
|
||||
// Only refresh when something actually changed in the item set.
|
||||
const hasChanges =
|
||||
(data?.ItemsAdded?.length ?? 0) > 0 ||
|
||||
(data?.ItemsRemoved?.length ?? 0) > 0 ||
|
||||
(data?.ItemsUpdated?.length ?? 0) > 0 ||
|
||||
(data?.FoldersAddedTo?.length ?? 0) > 0 ||
|
||||
(data?.FoldersRemovedFrom?.length ?? 0) > 0;
|
||||
|
||||
if (!hasChanges) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A single scan can emit several LibraryChanged messages in quick
|
||||
// succession, so debounce the invalidation to refetch only once.
|
||||
if (libraryChangeDebounceRef.current) {
|
||||
clearTimeout(libraryChangeDebounceRef.current);
|
||||
}
|
||||
libraryChangeDebounceRef.current = setTimeout(() => {
|
||||
for (const queryKey of LIBRARY_CHANGE_QUERY_KEYS) {
|
||||
queryClient.invalidateQueries({ queryKey: [...queryKey] });
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessage) {
|
||||
return;
|
||||
}
|
||||
if (lastMessage.MessageType === "Play") {
|
||||
handlePlayCommand(lastMessage.Data);
|
||||
} else if (lastMessage.MessageType === "LibraryChanged") {
|
||||
handleLibraryChanged(lastMessage.Data);
|
||||
}
|
||||
}, [lastMessage, router]);
|
||||
}, [lastMessage, router, handleLibraryChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (libraryChangeDebounceRef.current) {
|
||||
clearTimeout(libraryChangeDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePlayCommand = useCallback(
|
||||
(data: any) => {
|
||||
if (!data || !data.ItemIds || !data.ItemIds.length) {
|
||||
if (!data?.ItemIds?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,7 +209,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
|
||||
}, [connectWebSocket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId || !api || !api?.accessToken || !isNetworkConnected) {
|
||||
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user