Merge remote-tracking branch 'origin/develop' into feat/local-intros

Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com>
This commit is contained in:
Lance Chant
2026-07-17 14:25:05 +02:00
545 changed files with 53870 additions and 13458 deletions

View File

@@ -4,28 +4,68 @@ import type { DownloadedItem, DownloadsDatabase } from "./types";
const DOWNLOADS_DATABASE_KEY = "downloads.v2.json";
// Performance optimization: Cache the parsed database to avoid repeated JSON.parse calls
let cachedDb: DownloadsDatabase | null = null;
let cacheVersion = 0;
// Performance optimization: Cache the flattened items array
let cachedItems: DownloadedItem[] | null = null;
let itemsCacheVersion = -1;
// Performance optimization: Index for O(1) item lookups by ID
let itemIndex: Map<string, DownloadedItem> | null = null;
let indexCacheVersion = -1;
/**
* Get the downloads database from storage
* PERFORMANCE: Caches the parsed database to avoid repeated JSON.parse calls.
* NOTE: Returns the shared cached instance — do NOT mutate it directly. Go
* through addDownloadedItem/updateDownloadedItem/removeDownloadedItem so
* saveDownloadsDatabase() runs and the derived caches stay consistent.
*/
export function getDownloadsDatabase(): DownloadsDatabase {
// Return cached database if available
if (cachedDb !== null) {
return cachedDb;
}
// Parse from storage and cache the result
const file = storage.getString(DOWNLOADS_DATABASE_KEY);
if (file) {
return JSON.parse(file) as DownloadsDatabase;
cachedDb = JSON.parse(file) as DownloadsDatabase;
return cachedDb;
}
return { movies: {}, series: {}, other: {} };
const emptyDb = { movies: {}, series: {}, other: {} };
cachedDb = emptyDb;
return emptyDb;
}
/**
* Save the downloads database to storage
* PERFORMANCE: Updates cache and invalidates derived caches
*/
export function saveDownloadsDatabase(db: DownloadsDatabase): void {
storage.set(DOWNLOADS_DATABASE_KEY, JSON.stringify(db));
// Update the cache with the new database
cachedDb = db;
// Invalidate derived caches (items array and index)
cachedItems = null;
itemIndex = null;
cacheVersion++;
}
/**
* Get all downloaded items as a flat array
* PERFORMANCE: Caches the flattened array to avoid rebuilding on every call
*/
export function getAllDownloadedItems(): DownloadedItem[] {
// Return cached items if available and up-to-date
if (cachedItems !== null && itemsCacheVersion === cacheVersion) {
return cachedItems;
}
// Build the items array from the database
const db = getDownloadsDatabase();
const items: DownloadedItem[] = [];
@@ -47,34 +87,41 @@ export function getAllDownloadedItems(): DownloadedItem[] {
}
}
// Cache the result
cachedItems = items;
itemsCacheVersion = cacheVersion;
return items;
}
/**
* Get a downloaded item by its ID
* Build or refresh the item index for O(1) lookups
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
const db = getDownloadsDatabase();
if (db.movies[id]) {
return db.movies[id];
function ensureItemIndex(): void {
if (itemIndex !== null && indexCacheVersion === cacheVersion) {
return; // Index is up-to-date
}
for (const series of Object.values(db.series)) {
for (const season of Object.values(series.seasons)) {
for (const episode of Object.values(season.episodes)) {
if (episode.item.Id === id) {
return episode;
}
}
// Build new index from all items
itemIndex = new Map<string, DownloadedItem>();
const items = getAllDownloadedItems();
for (const item of items) {
if (item.item.Id) {
itemIndex.set(item.item.Id, item);
}
}
if (db.other?.[id]) {
return db.other[id];
}
indexCacheVersion = cacheVersion;
}
return undefined;
/**
* Get a downloaded item by its ID
* PERFORMANCE: Uses O(1) index lookup instead of O(n²) iteration
*/
export function getDownloadedItemById(id: string): DownloadedItem | undefined {
ensureItemIndex();
return itemIndex!.get(id);
}
/**
@@ -221,4 +268,5 @@ export function updateDownloadedItem(
*/
export function clearAllDownloadedItems(): void {
saveDownloadsDatabase({ movies: {}, series: {}, other: {} });
// saveDownloadsDatabase already invalidates caches
}

View File

@@ -96,5 +96,24 @@ export function getDownloadedItemSize(id: string): number {
*/
export function calculateTotalDownloadedSize(): number {
const items = getAllDownloadedItems();
return items.reduce((sum, item) => sum + (item.videoFileSize || 0), 0);
return items.reduce((sum, item) => {
// Trickplay bytes count too — getDownloadedItemSize models per-item size
// as video + trickplay, the total must match.
const trickplaySize = item.trickPlayData?.size ?? 0;
// Read the live file size on disk so the total reflects actual usage and
// self-heals items whose stored videoFileSize is 0 (old schema, or
// `fileInfo.size` was undefined at download time). Fall back to the stored
// value if the file can't be stat'd.
if (item.videoFilePath) {
try {
const file = new File(filePathToUri(item.videoFilePath));
if (file.exists) {
return sum + (file.size ?? item.videoFileSize ?? 0) + trickplaySize;
}
} catch (error) {
console.warn("Failed to stat downloaded file for size:", error);
}
}
return sum + (item.videoFileSize ?? 0) + trickplaySize;
}, 0);
}

View File

@@ -142,31 +142,12 @@ export function useDownloadEventHandlers({
} else {
// Transcoding - estimate from bitrate
const process = processes.find((p) => p.id === processId);
console.log(
`[DPL] Transcoding detected, looking for process ${processId}, found:`,
process ? "yes" : "no",
);
if (process) {
console.log(`[DPL] Process bitrate:`, {
key: process.maxBitrate.key,
value: process.maxBitrate.value,
runTimeTicks: process.item.RunTimeTicks,
});
if (process.maxBitrate.value && process.item.RunTimeTicks) {
const { estimateDownloadSize } = require("@/utils/download");
estimatedTotalBytes = estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
console.log(
`[DPL] Calculated estimatedTotalBytes:`,
estimatedTotalBytes,
);
} else {
console.log(
`[DPL] Cannot estimate size - bitrate.value or RunTimeTicks missing`,
);
}
if (process?.maxBitrate.value && process.item.RunTimeTicks) {
const { estimateDownloadSize } = require("@/utils/download");
estimatedTotalBytes = estimateDownloadSize(
process.maxBitrate.value,
process.item.RunTimeTicks,
);
}
}

View File

@@ -289,7 +289,24 @@ export function useDownloadOperations({
);
const appSizeUsage = useCallback(async () => {
const totalSize = calculateTotalDownloadedSize();
let totalSize = calculateTotalDownloadedSize();
// Also count in-progress downloads (they write straight to their final
// path) so the growing file shows up as app usage instead of drifting
// into the generic device share until completion.
for (const process of processes) {
try {
const file = new File(
Paths.document,
`${generateFilename(process.item)}.mp4`,
);
if (file.exists) {
totalSize += file.size ?? 0;
}
} catch {
// File not created yet — ignore.
}
}
try {
const [freeDiskStorage, totalDiskCapacity] = await Promise.all([
@@ -310,7 +327,7 @@ export function useDownloadOperations({
appSize: totalSize,
};
}
}, []);
}, [processes]);
return {
startBackgroundDownload,

View File

@@ -5,10 +5,13 @@ import {
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { BackHandler, Platform } from "react-native";
interface ModalOptions {
enableDynamicSizing?: boolean;
snapPoints?: (string | number)[];
@@ -73,6 +76,25 @@ export const GlobalModalProvider: React.FC<GlobalModalProviderProps> = ({
});
}, []);
useEffect(() => {
if (Platform.OS !== "android") return;
const onBackPress = () => {
if (isVisible) {
hideModal();
return true;
}
return false;
};
const subscription = BackHandler.addEventListener(
"hardwareBackPress",
onBackPress,
);
return () => subscription.remove();
}, [isVisible, hideModal]);
const value = {
showModal,
hideModal,

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

View File

@@ -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,58 @@ import {
updateAccountToken,
} from "@/utils/secureCredentials";
import { store } from "@/utils/store";
import { clearTVDiscoverySafely } from "@/utils/tvDiscovery/sync";
import { APP_VERSION } from "@/utils/version";
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: APP_VERSION },
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 +110,7 @@ interface JellyfinContextValue {
) => Promise<void>;
logout: () => Promise<void>;
initiateQuickConnect: () => Promise<string | undefined>;
stopQuickConnectPolling: () => void;
loginWithSavedCredential: (
serverUrl: string,
userId: string,
@@ -85,42 +131,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: APP_VERSION },
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="${APP_VERSION}"`,
};
}, [deviceId]);
@@ -146,6 +196,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 +233,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 +279,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 +298,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
const removeServerMutation = useMutation({
mutationFn: async () => {
clearTVDiscoverySafely();
storage.remove("serverUrl");
setApi(null);
},
@@ -286,6 +348,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
savedAt: Date.now(),
securityType,
pinHash,
primaryImageTag: auth.data.User.PrimaryImageTag ?? undefined,
});
}
@@ -323,7 +386,7 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
default:
throw new Error(
t(
"login.an_unexpected_error_occured_did_you_enter_the_correct_url",
"login.an_unexpected_error_occurred_did_you_enter_the_correct_url",
),
);
}
@@ -338,7 +401,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 +410,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 +453,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 +464,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 +535,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 +582,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
@@ -509,31 +620,54 @@ export const JellyfinProvider: React.FC<{ children: ReactNode }> = ({
setUser(storedUser);
}
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// Dismiss splash screen with cached data immediately,
// fetch fresh user data in the background
setInitialLoaded(true);
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
try {
const response = await getUserApi(apiInstance).getCurrentUser();
setUser(response.data);
// Migrate current session to secure storage if not already saved
if (storedUser?.Id && storedUser?.Name) {
const existingCredential = await getAccountCredential(
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
username: storedUser.Name,
savedAt: Date.now(),
securityType: "none",
});
storedUser.Id,
);
if (!existingCredential) {
await saveAccountCredential({
serverUrl,
serverName: "",
token,
userId: storedUser.Id,
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,
});
}
}
} catch (e) {
// Background fetch failed — app already rendered with cached data
console.warn("Background user fetch failed, using cached data:", e);
}
} else {
setInitialLoaded(true);
}
} catch (e) {
console.error(e);
} finally {
setInitialLoaded(true);
}
};
@@ -549,6 +683,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 +723,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]);

View File

@@ -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,
@@ -34,6 +29,22 @@ import { settingsAtom } from "@/utils/atoms/settings";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { storage } from "@/utils/mmkv";
// 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",
@@ -116,6 +127,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,
);
@@ -132,6 +165,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 {
@@ -272,7 +347,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);
@@ -306,6 +382,8 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
// Setup TrackPlayer and AudioStorage
useEffect(() => {
if (!TrackPlayer) return;
const setupPlayer = async () => {
if (playerSetupRef.current) return;
@@ -354,19 +432,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);
};
@@ -553,14 +633,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;
@@ -568,7 +647,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;
}
@@ -641,7 +719,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;
@@ -856,11 +934,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(
@@ -905,6 +985,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;
@@ -964,6 +1045,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
]);
const previous = useCallback(async () => {
if (!TrackPlayer) return;
const position = await TrackPlayer.getProgress().then(
(p: Progress) => p.position,
);
@@ -1033,11 +1115,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,
@@ -1087,7 +1171,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;
@@ -1120,7 +1204,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();
@@ -1168,6 +1252,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();
@@ -1201,6 +1286,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 ||
@@ -1241,6 +1327,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
@@ -1253,7 +1340,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);
});
@@ -1296,6 +1383,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
);
const clearQueue = useCallback(async () => {
if (!TrackPlayer) return;
const currentIndex = await TrackPlayer.getActiveTrackIndex();
const queue = await TrackPlayer.getQueue();
@@ -1325,6 +1413,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
const jumpToIndex = useCallback(
async (index: number) => {
if (!TrackPlayer) return;
if (
index < 0 ||
index >= state.queue.length ||
@@ -1460,6 +1549,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;
@@ -1476,6 +1566,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();
@@ -1485,6 +1576,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;
@@ -1598,3 +1690,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;

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api";
import { router } from "expo-router";
import { useAtomValue } from "jotai";
import {
createContext,
@@ -11,10 +12,36 @@ import {
useState,
} from "react";
import { AppState, type AppStateStatus } from "react-native";
import useRouter from "@/hooks/useAppRouter";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
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;
// Query keys that depend on per-user playback state (resume position, played
// status, favorites) and should be refreshed when the server reports a
// `UserDataChanged`. Scoped to the progression-based sections so finishing an
// episode does not pointlessly refetch "recently added" or suggestions.
const USER_DATA_CHANGE_QUERY_KEYS = [
["home", "continueAndNextUp"],
["home", "resumeItems"],
["home", "nextUp-all"],
["home", "heroItems"],
["resumeItems"],
["nextUp-all"],
["nextUp"],
] as const;
interface WebSocketMessage {
MessageType: string;
Data: any;
@@ -25,10 +52,30 @@ interface WebSocketProviderProps {
children: ReactNode;
}
/**
* Handler invoked for every message of a given `MessageType`. Receives the
* message `Data` payload and the full message.
*/
type WebSocketMessageHandler = (data: any, message: WebSocketMessage) => void;
interface WebSocketContextType {
ws: WebSocket | null;
isConnected: boolean;
/**
* @deprecated Prefer `subscribe`. `lastMessage` only keeps the most recent
* message, so bursts arriving in the same tick are coalesced and lost. Kept
* for `useWebsockets` (GeneralCommand handling) until it is migrated.
*/
lastMessage: WebSocketMessage | null;
/**
* Subscribe to a given message type. The handler is called synchronously for
* every matching message (no coalescing, unlike `lastMessage`). Returns an
* unsubscribe function to call on cleanup.
*/
subscribe: (
messageType: string,
handler: WebSocketMessageHandler,
) => () => void;
sendMessage: (message: any) => void;
clearLastMessage: () => void;
}
@@ -41,13 +88,84 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const router = useRouter();
const queryClient = useNetworkAwareQueryClient();
const deviceId = useMemo(() => {
return getOrSetDeviceId();
}, []);
const reconnectAttemptsRef = useRef(0);
const libraryChangeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const userDataChangeDebounceRef = useRef<ReturnType<
typeof setTimeout
> | null>(null);
// Handle for the onerror backoff timer. Tracked so a reconnect triggered by
// another path (foreground, network reconnect, effect re-run) can cancel a
// pending one — an untracked timer would later open a second socket.
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// Pub/sub registry: messageType -> set of handlers. Stored in a ref so
// subscribing/dispatching never triggers a re-render.
const listenersRef = useRef<Map<string, Set<WebSocketMessageHandler>>>(
new Map(),
);
const subscribe = useCallback(
(messageType: string, handler: WebSocketMessageHandler) => {
const listeners = listenersRef.current;
let handlers = listeners.get(messageType);
if (!handlers) {
handlers = new Set();
listeners.set(messageType, handlers);
}
handlers.add(handler);
return () => {
handlers?.delete(handler);
// Only drop the map entry if it still points at THIS set. After an
// unsubscribe + re-subscribe for the same type, a stale second call to
// this cleanup would otherwise delete the new subscribers' set and
// silently stop delivering their messages.
if (
handlers &&
handlers.size === 0 &&
listeners.get(messageType) === handlers
) {
listeners.delete(messageType);
}
};
},
[],
);
const dispatchMessage = useCallback((message: WebSocketMessage) => {
const handlers = listenersRef.current.get(message.MessageType);
if (!handlers || handlers.size === 0) return;
// Copy to tolerate handlers that unsubscribe during dispatch.
for (const handler of [...handlers]) {
// Isolate each handler so one throwing subscriber can't abort the rest
// (and isn't misreported as a parse failure by the outer onmessage catch).
try {
handler(message.Data, message);
} catch (error) {
console.error(
`Error handling WebSocket message type "${message.MessageType}":`,
error,
);
}
}
}, []);
const connectWebSocket = useCallback(() => {
// Cancel any reconnect queued by a previous onerror before opening a new
// socket, so we never end up with two live sockets — each would double the
// message fan-out and double-invalidate queries.
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
return;
}
@@ -66,9 +184,12 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
const reconnectDelay = 10000;
newWebSocket.onopen = () => {
console.log("WebSocket connection opened");
setIsConnected(true);
reconnectAttemptsRef.current = 0;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
keepAliveInterval = setInterval(() => {
if (newWebSocket.readyState === WebSocket.OPEN) {
newWebSocket.send(JSON.stringify({ MessageType: "KeepAlive" }));
@@ -80,9 +201,15 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
// Don't log errors - this is expected when offline or server unreachable
setIsConnected(false);
// Replace any still-pending reconnect so only one is ever queued; the
// previously untracked handle could leak and open a second socket.
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
setTimeout(() => {
reconnectTimeoutRef.current = setTimeout(() => {
reconnectTimeoutRef.current = null;
connectWebSocket();
}, reconnectDelay);
}
@@ -97,7 +224,10 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
newWebSocket.onmessage = (e) => {
try {
const message = JSON.parse(e.data);
setLastMessage(message); // Store the last message in context
// Legacy single-slot state, still consumed by useWebsockets.
setLastMessage(message);
// Pub/sub: deliver to every subscriber without coalescing.
dispatchMessage(message);
} catch (error) {
console.error("Error parsing WebSocket message:", error);
}
@@ -108,41 +238,117 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
if (keepAliveInterval) {
clearInterval(keepAliveInterval);
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
newWebSocket.close();
};
}, [api, deviceId, isNetworkConnected]);
}, [api, deviceId, isNetworkConnected, dispatchMessage]);
useEffect(() => {
if (!lastMessage) {
return;
}
if (lastMessage.MessageType === "Play") {
handlePlayCommand(lastMessage.Data);
}
}, [lastMessage, router]);
const handlePlayCommand = useCallback(
const handleLibraryChanged = useCallback(
(data: any) => {
if (!data || !data.ItemIds || !data.ItemIds.length) {
// 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;
}
const itemId = data.ItemIds[0];
router.push({
pathname: "/(auth)/player/direct-player",
params: {
itemId: itemId,
playCommand: data.PlayCommand || "PlayNow",
audioIndex: data.AudioStreamIndex?.toString(),
subtitleIndex: data.SubtitleStreamIndex?.toString(),
mediaSourceId: data.MediaSourceId || "",
bitrateValue: "",
offline: "false",
},
});
// 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);
},
[router],
[queryClient],
);
const handleUserDataChanged = useCallback(
(data: any) => {
// Jellyfin sends UserDataChanged when playback position, played status
// or favorites change (e.g. finishing an episode). Only the
// progression-based home sections care about it.
if (!((data?.UserDataList?.length ?? 0) > 0)) {
return;
}
// Finishing an item can emit several UserDataChanged messages, so
// debounce to invalidate the affected sections only once.
if (userDataChangeDebounceRef.current) {
clearTimeout(userDataChangeDebounceRef.current);
}
userDataChangeDebounceRef.current = setTimeout(() => {
for (const queryKey of USER_DATA_CHANGE_QUERY_KEYS) {
queryClient.invalidateQueries({ queryKey: [...queryKey] });
}
}, 800);
},
[queryClient],
);
// Refresh library-dependent queries when the server reports a change.
useEffect(
() => subscribe("LibraryChanged", handleLibraryChanged),
[subscribe, handleLibraryChanged],
);
// Refresh "Continue Watching" / "Next Up" when playback state changes.
useEffect(
() => subscribe("UserDataChanged", handleUserDataChanged),
[subscribe, handleUserDataChanged],
);
useEffect(() => {
return () => {
if (libraryChangeDebounceRef.current) {
clearTimeout(libraryChangeDebounceRef.current);
}
if (userDataChangeDebounceRef.current) {
clearTimeout(userDataChangeDebounceRef.current);
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, []);
const handlePlayCommand = useCallback((data: any) => {
if (!data?.ItemIds?.length) {
return;
}
const itemId = data.ItemIds[0];
router.push({
pathname: "/(auth)/player/direct-player",
params: {
itemId: itemId,
playCommand: data.PlayCommand || "PlayNow",
audioIndex: data.AudioStreamIndex?.toString(),
subtitleIndex: data.SubtitleStreamIndex?.toString(),
mediaSourceId: data.MediaSourceId || "",
bitrateValue: "",
offline: "false",
},
});
}, []);
// Server-initiated "Play me this item" remote command.
useEffect(
() => subscribe("Play", handlePlayCommand),
[subscribe, handlePlayCommand],
);
useEffect(() => {
@@ -151,7 +357,7 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, [connectWebSocket]);
useEffect(() => {
if (!deviceId || !api || !api?.accessToken || !isNetworkConnected) {
if (!deviceId || !api?.accessToken || !isNetworkConnected) {
return;
}
@@ -212,7 +418,14 @@ export const WebSocketProvider = ({ children }: WebSocketProviderProps) => {
}, []);
return (
<WebSocketContext.Provider
value={{ ws, isConnected, lastMessage, sendMessage, clearLastMessage }}
value={{
ws,
isConnected,
lastMessage,
subscribe,
sendMessage,
clearLastMessage,
}}
>
{children}
</WebSocketContext.Provider>