mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 02:04:35 +01:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
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,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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user