mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-18 10:14:17 +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:
18
hooks/useControlsSafeAreaInsets.ts
Normal file
18
hooks/useControlsSafeAreaInsets.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
type EdgeInsets,
|
||||
useSafeAreaInsets,
|
||||
} from "react-native-safe-area-context";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const ZERO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
/**
|
||||
* Returns safe-area insets to apply to in-player controls, honoring the
|
||||
* `safeAreaInControlsEnabled` user setting. When the setting is disabled,
|
||||
* returns zero insets so controls can sit flush against the screen edges.
|
||||
*/
|
||||
export const useControlsSafeAreaInsets = (): EdgeInsets => {
|
||||
const { settings } = useSettings();
|
||||
const insets = useSafeAreaInsets();
|
||||
return settings.safeAreaInControlsEnabled ? insets : ZERO_INSETS;
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useSharedValue } from "react-native-reanimated";
|
||||
|
||||
export const useControlsVisibility = (timeout = 3000) => {
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const hideControlsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const showControls = useCallback(() => {
|
||||
opacity.value = 1;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
hideControlsTimerRef.current = setTimeout(() => {
|
||||
opacity.value = 0;
|
||||
}, timeout);
|
||||
}, [timeout]);
|
||||
|
||||
const hideControls = useCallback(() => {
|
||||
opacity.value = 0;
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hideControlsTimerRef.current) {
|
||||
clearTimeout(hideControlsTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { opacity, showControls, hideControls };
|
||||
};
|
||||
@@ -1,16 +1,27 @@
|
||||
import { type BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useMemo } from "react";
|
||||
import type { Settings } from "@/utils/atoms/settings";
|
||||
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
import {
|
||||
getDefaultPlaySettings,
|
||||
type PlaySettingsOptions,
|
||||
} from "@/utils/jellyfin/getDefaultPlaySettings";
|
||||
|
||||
/**
|
||||
* React hook wrapper for getDefaultPlaySettings.
|
||||
* Used in UI components for initial playback (no previous track state).
|
||||
*
|
||||
* @param item - The media item to play
|
||||
* @param settings - User settings (language preferences, bitrate, etc.)
|
||||
* @param options - Optional flags to control behavior (e.g., applyLanguagePreferences for TV)
|
||||
*/
|
||||
const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) =>
|
||||
const useDefaultPlaySettings = (
|
||||
item: BaseItemDto | null | undefined,
|
||||
settings: Settings | null,
|
||||
options?: PlaySettingsOptions,
|
||||
) =>
|
||||
useMemo(() => {
|
||||
const { mediaSource, audioIndex, subtitleIndex, bitrate } =
|
||||
getDefaultPlaySettings(item, settings);
|
||||
getDefaultPlaySettings(item, settings, undefined, options);
|
||||
|
||||
return {
|
||||
defaultMediaSource: mediaSource,
|
||||
@@ -18,6 +29,6 @@ const useDefaultPlaySettings = (item: BaseItemDto, settings: Settings | null) =>
|
||||
defaultSubtitleIndex: subtitleIndex,
|
||||
defaultBitrate: bitrate,
|
||||
};
|
||||
}, [item, settings]);
|
||||
}, [item, settings, options]);
|
||||
|
||||
export default useDefaultPlaySettings;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { usePlaySettings } from "@/providers/PlaySettingsProvider";
|
||||
import { writeToLog } from "@/utils/log";
|
||||
|
||||
export const useDownloadedFileOpener = () => {
|
||||
const router = useRouter();
|
||||
const { setPlayUrl, setOfflineSettings } = usePlaySettings();
|
||||
|
||||
const openFile = useCallback(
|
||||
async (item: BaseItemDto) => {
|
||||
if (!item.Id) {
|
||||
writeToLog("ERROR", "Attempted to open a file without an ID.");
|
||||
console.error("Attempted to open a file without an ID.");
|
||||
return;
|
||||
}
|
||||
const queryParams = new URLSearchParams({
|
||||
itemId: item.Id,
|
||||
offline: "true",
|
||||
playbackPosition:
|
||||
item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
|
||||
});
|
||||
try {
|
||||
router.push(`/player/direct-player?${queryParams.toString()}`);
|
||||
} catch (error) {
|
||||
writeToLog("ERROR", "Error opening file", error);
|
||||
console.error("Error opening file:", error);
|
||||
}
|
||||
},
|
||||
[setOfflineSettings, setPlayUrl, router],
|
||||
);
|
||||
|
||||
return { openFile };
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import type * as ImageColorsType from "react-native-image-colors";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
// Conditionally import react-native-image-colors only on non-TV platforms
|
||||
const ImageColors = Platform.isTV
|
||||
? null
|
||||
: (require("react-native-image-colors") as typeof ImageColorsType);
|
||||
|
||||
import {
|
||||
adjustToNearBlack,
|
||||
calculateTextColor,
|
||||
isCloseToBlack,
|
||||
itemThemeColorAtom,
|
||||
} from "@/utils/atoms/primaryColor";
|
||||
import { getItemImage } from "@/utils/getItemImage";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
/**
|
||||
* Custom hook to extract and manage image colors for a given item.
|
||||
*
|
||||
* @param item - The BaseItemDto object representing the item.
|
||||
* @param disabled - A boolean flag to disable color extraction.
|
||||
*
|
||||
*/
|
||||
export const useImageColors = ({
|
||||
item,
|
||||
url,
|
||||
disabled,
|
||||
}: {
|
||||
item?: BaseItemDto | null;
|
||||
url?: string | null;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const [, setPrimaryColor] = useAtom(itemThemeColorAtom);
|
||||
|
||||
const isTv = Platform.isTV;
|
||||
|
||||
const source = useMemo(() => {
|
||||
if (!api) return;
|
||||
if (url) return { uri: url };
|
||||
if (item)
|
||||
return getItemImage({
|
||||
item,
|
||||
api,
|
||||
variant: "Primary",
|
||||
quality: 80,
|
||||
width: 300,
|
||||
});
|
||||
return null;
|
||||
}, [api, item, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTv) return;
|
||||
if (disabled) return;
|
||||
if (source?.uri) {
|
||||
const _primary = storage.getString(`${source.uri}-primary`);
|
||||
const _text = storage.getString(`${source.uri}-text`);
|
||||
|
||||
if (_primary && _text) {
|
||||
setPrimaryColor({
|
||||
primary: _primary,
|
||||
text: _text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract colors from the image
|
||||
if (!ImageColors?.getColors) return;
|
||||
|
||||
ImageColors.getColors(source.uri, {
|
||||
fallback: "#fff",
|
||||
cache: false,
|
||||
})
|
||||
.then((colors: ImageColorsType.ImageColorsResult) => {
|
||||
let primary = "#fff";
|
||||
let text = "#000";
|
||||
let backup = "#fff";
|
||||
|
||||
// Select the appropriate color based on the platform
|
||||
if (colors.platform === "android") {
|
||||
primary = colors.dominant;
|
||||
backup = colors.vibrant;
|
||||
} else if (colors.platform === "ios") {
|
||||
primary = colors.detail;
|
||||
backup = colors.primary;
|
||||
}
|
||||
|
||||
// Adjust the primary color if it's too close to black
|
||||
if (primary && isCloseToBlack(primary)) {
|
||||
if (backup && !isCloseToBlack(backup)) primary = backup;
|
||||
primary = adjustToNearBlack(primary);
|
||||
}
|
||||
|
||||
// Calculate the text color based on the primary color
|
||||
if (primary) text = calculateTextColor(primary);
|
||||
|
||||
setPrimaryColor({
|
||||
primary,
|
||||
text,
|
||||
});
|
||||
|
||||
// Cache the colors in storage
|
||||
if (source.uri && primary) {
|
||||
storage.set(`${source.uri}-primary`, primary);
|
||||
storage.set(`${source.uri}-text`, text);
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error("Error getting colors", error);
|
||||
});
|
||||
}
|
||||
}, [isTv, source?.uri, setPrimaryColor, disabled]);
|
||||
|
||||
if (isTv) return;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { File, Paths } from "expo-file-system";
|
||||
import { useCallback } from "react";
|
||||
import { storage } from "@/utils/mmkv";
|
||||
|
||||
@@ -12,36 +13,28 @@ const useImageStorage = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* expo-file-system instead of fetch+Blob+FileReader: the latter silently
|
||||
* resolves to an empty payload under RN's New Architecture.
|
||||
*/
|
||||
const image2Base64 = useCallback(async (url?: string | null) => {
|
||||
if (!url) return null;
|
||||
|
||||
let blob: Blob;
|
||||
const tmpFile = new File(
|
||||
Paths.cache,
|
||||
`img-${Date.now()}-${Math.random().toString(36).slice(2)}.jpg`,
|
||||
);
|
||||
try {
|
||||
// Fetch the data from the URL
|
||||
const response = await fetch(url);
|
||||
blob = await response.blob();
|
||||
const downloaded = await File.downloadFileAsync(url, tmpFile, {
|
||||
idempotent: true,
|
||||
});
|
||||
return await downloaded.base64();
|
||||
} catch (error) {
|
||||
console.warn("Error fetching image:", error);
|
||||
return null;
|
||||
} finally {
|
||||
if (tmpFile.exists) tmpFile.delete();
|
||||
}
|
||||
|
||||
// Create a FileReader instance
|
||||
const reader = new FileReader();
|
||||
|
||||
// Convert blob to base64
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
// Extract the base64 string (remove the data URL prefix)
|
||||
const base64 = reader.result.split(",")[1];
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to convert image to base64"));
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveImage = useCallback(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { Platform } from "react-native";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
|
||||
@@ -12,11 +13,17 @@ export const excludeFields = (fieldsToExclude: ItemFields[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
type ExtraQueryOptions = {
|
||||
gcTime?: number;
|
||||
staleTime?: number;
|
||||
};
|
||||
|
||||
export const useItemQuery = (
|
||||
itemId: string | undefined,
|
||||
isOffline?: boolean,
|
||||
fields?: ItemFields[],
|
||||
excludeFields?: ItemFields[],
|
||||
queryOptions?: ExtraQueryOptions,
|
||||
) => {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
@@ -49,9 +56,12 @@ export const useItemQuery = (
|
||||
return response.data.Items?.[0];
|
||||
},
|
||||
enabled: !!itemId,
|
||||
staleTime: isOffline ? Infinity : 60 * 1000,
|
||||
refetchInterval: !isOffline && Platform.isTV ? 60 * 1000 : undefined,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
networkMode: "always",
|
||||
...queryOptions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -80,7 +80,7 @@ export const usePlaybackManager = ({
|
||||
const { data: adjacentItems } = useQuery({
|
||||
queryKey: ["adjacentItems", item?.Id, item?.SeriesId, isOffline],
|
||||
queryFn: async (): Promise<BaseItemDto[] | null> => {
|
||||
if (!item || !item.SeriesId) {
|
||||
if (!item?.SeriesId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -109,30 +109,35 @@ export const usePlaybackManager = ({
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* Derive prev/next from the current item's real position in the adjacent
|
||||
* list rather than from the array length. `getEpisodes({ adjacentTo })` does
|
||||
* not guarantee a fixed [prev, current, next] shape — at the first/last
|
||||
* episode it can still return the current item as the first/last entry — so
|
||||
* length-based indexing wrongly surfaces the current episode as "previous".
|
||||
*/
|
||||
const currentIndex = useMemo(
|
||||
() => adjacentItems?.findIndex((e) => e.Id === item?.Id) ?? -1,
|
||||
[adjacentItems, item],
|
||||
);
|
||||
|
||||
/** A neighbour is only navigable if it has an actual media file (not a
|
||||
* "Virtual"/missing episode placeholder, e.g. an absent Special). */
|
||||
const isNavigable = (episode?: BaseItemDto | null): episode is BaseItemDto =>
|
||||
!!episode && episode.Id !== item?.Id && episode.LocationType !== "Virtual";
|
||||
|
||||
const previousItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[0].Id === item?.Id ? null : adjacentItems[0];
|
||||
}
|
||||
|
||||
return adjacentItems[0];
|
||||
}, [adjacentItems, item]);
|
||||
if (!adjacentItems || currentIndex <= 0) return null;
|
||||
const candidate = adjacentItems[currentIndex - 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, item]);
|
||||
|
||||
/** The next item in the series */
|
||||
const nextItem = useMemo(() => {
|
||||
if (!adjacentItems || adjacentItems.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (adjacentItems.length === 2) {
|
||||
return adjacentItems[1].Id === item?.Id ? null : adjacentItems[1];
|
||||
}
|
||||
|
||||
return adjacentItems[2];
|
||||
}, [adjacentItems, item]);
|
||||
if (!adjacentItems || currentIndex < 0) return null;
|
||||
const candidate = adjacentItems[currentIndex + 1];
|
||||
return isNavigable(candidate) ? candidate : null;
|
||||
}, [adjacentItems, currentIndex, item]);
|
||||
|
||||
/**
|
||||
* Reports playback progress.
|
||||
|
||||
50
hooks/useRefreshLibraryOnFocus.ts
Normal file
50
hooks/useRefreshLibraryOnFocus.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
|
||||
|
||||
// Query keys that depend on the set of library items. Kept in sync with the
|
||||
// LibraryChanged handler in WebSocketProvider.
|
||||
const LIBRARY_QUERY_KEYS = [
|
||||
["home"],
|
||||
["library-items"],
|
||||
["nextUp-all"],
|
||||
["nextUp"],
|
||||
["resumeItems"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Fallback refresh for newly added/removed content.
|
||||
*
|
||||
* The primary path is the server's `LibraryChanged` WebSocket event (handled in
|
||||
* WebSocketProvider). This hook is a safety net for cases where the socket was
|
||||
* down or the change happened while the screen was unfocused: when the screen
|
||||
* regains focus, it invalidates the library-dependent queries so React Query
|
||||
* refetches the latest content.
|
||||
*
|
||||
* Skips the refresh on the very first focus (initial mount already fetches) and
|
||||
* throttles to avoid refetch storms when quickly switching tabs.
|
||||
*/
|
||||
export function useRefreshLibraryOnFocus(throttleMs = 30_000) {
|
||||
const queryClient = useNetworkAwareQueryClient();
|
||||
const hasFocusedOnce = useRef(false);
|
||||
const lastRefreshRef = useRef(0);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!hasFocusedOnce.current) {
|
||||
hasFocusedOnce.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshRef.current < throttleMs) {
|
||||
return;
|
||||
}
|
||||
lastRefreshRef.current = now;
|
||||
|
||||
for (const queryKey of LIBRARY_QUERY_KEYS) {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
}, [queryClient, throttleMs]),
|
||||
);
|
||||
}
|
||||
332
hooks/useRemoteSubtitles.ts
Normal file
332
hooks/useRemoteSubtitles.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
RemoteSubtitleInfo,
|
||||
} from "@jellyfin/sdk/lib/generated-client";
|
||||
import { getSubtitleApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Directory, File, Paths } from "expo-file-system";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import {
|
||||
addDownloadedSubtitle,
|
||||
type DownloadedSubtitle,
|
||||
} from "@/utils/atoms/downloadedSubtitles";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
import {
|
||||
OpenSubtitlesApi,
|
||||
type OpenSubtitlesResult,
|
||||
} from "@/utils/opensubtitles/api";
|
||||
|
||||
export interface SubtitleSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
format: string;
|
||||
language: string;
|
||||
communityRating?: number;
|
||||
downloadCount?: number;
|
||||
isHashMatch?: boolean;
|
||||
hearingImpaired?: boolean;
|
||||
aiTranslated?: boolean;
|
||||
machineTranslated?: boolean;
|
||||
/** For OpenSubtitles: file ID to download */
|
||||
fileId?: number;
|
||||
/** Source: 'jellyfin' or 'opensubtitles' */
|
||||
source: "jellyfin" | "opensubtitles";
|
||||
}
|
||||
|
||||
interface UseRemoteSubtitlesOptions {
|
||||
itemId: string;
|
||||
item: BaseItemDto;
|
||||
mediaSourceId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Jellyfin RemoteSubtitleInfo to unified SubtitleSearchResult
|
||||
*/
|
||||
function jellyfinToResult(sub: RemoteSubtitleInfo): SubtitleSearchResult {
|
||||
return {
|
||||
id: sub.Id ?? "",
|
||||
name: sub.Name ?? "Unknown",
|
||||
providerName: sub.ProviderName ?? "Unknown",
|
||||
format: sub.Format ?? "srt",
|
||||
language: sub.ThreeLetterISOLanguageName ?? "",
|
||||
communityRating: sub.CommunityRating ?? undefined,
|
||||
downloadCount: sub.DownloadCount ?? undefined,
|
||||
isHashMatch: sub.IsHashMatch ?? undefined,
|
||||
hearingImpaired: sub.HearingImpaired ?? undefined,
|
||||
aiTranslated: sub.AiTranslated ?? undefined,
|
||||
machineTranslated: sub.MachineTranslated ?? undefined,
|
||||
source: "jellyfin",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenSubtitles result to unified SubtitleSearchResult
|
||||
*/
|
||||
function openSubtitlesToResult(
|
||||
sub: OpenSubtitlesResult,
|
||||
): SubtitleSearchResult | null {
|
||||
const firstFile = sub.attributes.files[0];
|
||||
if (!firstFile) return null;
|
||||
|
||||
return {
|
||||
id: sub.id,
|
||||
name:
|
||||
sub.attributes.release || sub.attributes.files[0]?.file_name || "Unknown",
|
||||
providerName: "OpenSubtitles",
|
||||
format: sub.attributes.format || "srt",
|
||||
language: sub.attributes.language,
|
||||
communityRating: sub.attributes.ratings,
|
||||
downloadCount: sub.attributes.download_count,
|
||||
isHashMatch: false,
|
||||
hearingImpaired: sub.attributes.hearing_impaired,
|
||||
aiTranslated: sub.attributes.ai_translated,
|
||||
machineTranslated: sub.attributes.machine_translated,
|
||||
fileId: firstFile.file_id,
|
||||
source: "opensubtitles",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for searching and downloading remote subtitles
|
||||
*
|
||||
* Primary: Uses Jellyfin's subtitle API (server-side OpenSubtitles plugin)
|
||||
* Fallback: Direct OpenSubtitles API when server has no provider
|
||||
*/
|
||||
export function useRemoteSubtitles({
|
||||
itemId,
|
||||
item,
|
||||
mediaSourceId: _mediaSourceId,
|
||||
}: UseRemoteSubtitlesOptions) {
|
||||
const api = useAtomValue(apiAtom);
|
||||
const { settings } = useSettings();
|
||||
const openSubtitlesApiKey = settings.openSubtitlesApiKey;
|
||||
|
||||
// Check if we can use OpenSubtitles fallback
|
||||
const hasOpenSubtitlesApiKey = Boolean(openSubtitlesApiKey);
|
||||
|
||||
// Create OpenSubtitles API client when API key is available
|
||||
const openSubtitlesApi = useMemo(() => {
|
||||
if (!openSubtitlesApiKey) return null;
|
||||
return new OpenSubtitlesApi(openSubtitlesApiKey);
|
||||
}, [openSubtitlesApiKey]);
|
||||
|
||||
/**
|
||||
* Search for subtitles via Jellyfin API
|
||||
*/
|
||||
const searchJellyfin = useCallback(
|
||||
async (language: string): Promise<SubtitleSearchResult[]> => {
|
||||
if (!api) throw new Error("API not available");
|
||||
|
||||
const subtitleApi = getSubtitleApi(api);
|
||||
const response = await subtitleApi.searchRemoteSubtitles({
|
||||
itemId,
|
||||
language,
|
||||
});
|
||||
|
||||
return (response.data || []).map(jellyfinToResult);
|
||||
},
|
||||
[api, itemId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Search for subtitles via OpenSubtitles direct API
|
||||
*/
|
||||
const searchOpenSubtitles = useCallback(
|
||||
async (language: string): Promise<SubtitleSearchResult[]> => {
|
||||
if (!openSubtitlesApi) {
|
||||
throw new Error("OpenSubtitles API key not configured");
|
||||
}
|
||||
|
||||
// Get IMDB ID from item if available
|
||||
const imdbId = item.ProviderIds?.Imdb;
|
||||
|
||||
// Build search params
|
||||
const params: Parameters<OpenSubtitlesApi["search"]>[0] = {
|
||||
languages: language,
|
||||
};
|
||||
|
||||
if (imdbId) {
|
||||
params.imdbId = imdbId;
|
||||
} else {
|
||||
// Fall back to title search
|
||||
params.query = item.Name || "";
|
||||
params.year = item.ProductionYear || undefined;
|
||||
}
|
||||
|
||||
// For TV episodes, add season/episode info
|
||||
if (item.Type === "Episode") {
|
||||
params.seasonNumber = item.ParentIndexNumber || undefined;
|
||||
params.episodeNumber = item.IndexNumber || undefined;
|
||||
}
|
||||
|
||||
const response = await openSubtitlesApi.search(params);
|
||||
|
||||
return response.data
|
||||
.map(openSubtitlesToResult)
|
||||
.filter((r): r is SubtitleSearchResult => r !== null);
|
||||
},
|
||||
[openSubtitlesApi, item],
|
||||
);
|
||||
|
||||
/**
|
||||
* Download subtitle via Jellyfin API (saves to server library)
|
||||
*/
|
||||
const downloadJellyfin = useCallback(
|
||||
async (subtitleId: string): Promise<void> => {
|
||||
if (!api) throw new Error("API not available");
|
||||
|
||||
const subtitleApi = getSubtitleApi(api);
|
||||
await subtitleApi.downloadRemoteSubtitles({
|
||||
itemId,
|
||||
subtitleId,
|
||||
});
|
||||
},
|
||||
[api, itemId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Download subtitle via OpenSubtitles API (returns local file path)
|
||||
*
|
||||
* On TV: Downloads to cache directory and persists metadata in MMKV
|
||||
* On mobile: Downloads to cache directory (ephemeral, no persistence)
|
||||
*
|
||||
* Uses a flat filename structure with itemId prefix to avoid tvOS permission issues
|
||||
*/
|
||||
const downloadOpenSubtitles = useCallback(
|
||||
async (
|
||||
fileId: number,
|
||||
result: SubtitleSearchResult,
|
||||
): Promise<{ path: string; subtitle?: DownloadedSubtitle }> => {
|
||||
if (!openSubtitlesApi) {
|
||||
throw new Error("OpenSubtitles API key not configured");
|
||||
}
|
||||
|
||||
// Get download link
|
||||
const response = await openSubtitlesApi.download(fileId);
|
||||
const originalFileName = response.file_name || `subtitle_${fileId}.srt`;
|
||||
|
||||
// Use cache directory for both platforms (tvOS has permission issues with documents)
|
||||
// TV: Uses itemId prefix for organization and persists metadata
|
||||
// Mobile: Simple filename, no persistence
|
||||
const subtitlesDir = new Directory(Paths.cache, "streamyfin-subtitles");
|
||||
|
||||
// Ensure directory exists
|
||||
if (!subtitlesDir.exists) {
|
||||
subtitlesDir.create();
|
||||
}
|
||||
|
||||
// TV: Prefix filename with itemId for organization
|
||||
// Mobile: Use original filename
|
||||
const fileName = Platform.isTV
|
||||
? `${itemId}_${originalFileName}`
|
||||
: originalFileName;
|
||||
|
||||
// Create file and download
|
||||
const destination = new File(subtitlesDir, fileName);
|
||||
|
||||
// Delete existing file if it exists (re-download)
|
||||
if (destination.exists) {
|
||||
destination.delete();
|
||||
}
|
||||
|
||||
await File.downloadFileAsync(response.link, destination);
|
||||
|
||||
// TV: Persist metadata for future sessions
|
||||
if (Platform.isTV) {
|
||||
const subtitleMetadata: DownloadedSubtitle = {
|
||||
id: result.id,
|
||||
itemId,
|
||||
filePath: destination.uri,
|
||||
name: result.name,
|
||||
language: result.language,
|
||||
format: result.format,
|
||||
source: "opensubtitles",
|
||||
downloadedAt: Date.now(),
|
||||
};
|
||||
addDownloadedSubtitle(subtitleMetadata);
|
||||
return { path: destination.uri, subtitle: subtitleMetadata };
|
||||
}
|
||||
|
||||
return { path: destination.uri };
|
||||
},
|
||||
[openSubtitlesApi, itemId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Search mutation - tries Jellyfin first, falls back to OpenSubtitles
|
||||
*/
|
||||
const searchMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
language,
|
||||
preferOpenSubtitles = false,
|
||||
}: {
|
||||
language: string;
|
||||
preferOpenSubtitles?: boolean;
|
||||
}) => {
|
||||
// If user prefers OpenSubtitles and has API key, use it
|
||||
if (preferOpenSubtitles && hasOpenSubtitlesApiKey) {
|
||||
return searchOpenSubtitles(language);
|
||||
}
|
||||
|
||||
// Try Jellyfin first
|
||||
try {
|
||||
const results = await searchJellyfin(language);
|
||||
// If no results and we have OpenSubtitles fallback, try it
|
||||
if (results.length === 0 && hasOpenSubtitlesApiKey) {
|
||||
return searchOpenSubtitles(language);
|
||||
}
|
||||
return results;
|
||||
} catch (error) {
|
||||
// If Jellyfin fails (no provider configured) and we have fallback, use it
|
||||
if (hasOpenSubtitlesApiKey) {
|
||||
return searchOpenSubtitles(language);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Download mutation
|
||||
*/
|
||||
const downloadMutation = useMutation({
|
||||
mutationFn: async (result: SubtitleSearchResult) => {
|
||||
if (result.source === "jellyfin") {
|
||||
await downloadJellyfin(result.id);
|
||||
return { type: "server" as const };
|
||||
}
|
||||
if (result.fileId) {
|
||||
const { path, subtitle } = await downloadOpenSubtitles(
|
||||
result.fileId,
|
||||
result,
|
||||
);
|
||||
return { type: "local" as const, path, subtitle };
|
||||
}
|
||||
throw new Error("Invalid subtitle result");
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
// State
|
||||
hasOpenSubtitlesApiKey,
|
||||
isSearching: searchMutation.isPending,
|
||||
isDownloading: downloadMutation.isPending,
|
||||
searchError: searchMutation.error,
|
||||
downloadError: downloadMutation.error,
|
||||
searchResults: searchMutation.data,
|
||||
|
||||
// Actions
|
||||
search: searchMutation.mutate,
|
||||
searchAsync: searchMutation.mutateAsync,
|
||||
download: downloadMutation.mutate,
|
||||
downloadAsync: downloadMutation.mutateAsync,
|
||||
reset: () => {
|
||||
searchMutation.reset();
|
||||
downloadMutation.reset();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export const useSessions = ({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
if (!api || !user?.Policy?.IsAdministrator) {
|
||||
return [];
|
||||
}
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
@@ -55,7 +55,7 @@ export const useAllSessions = ({
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["allSessions"],
|
||||
queryFn: async () => {
|
||||
if (!api || !user || !user.Policy?.IsAdministrator) {
|
||||
if (!api || !user?.Policy?.IsAdministrator) {
|
||||
return [];
|
||||
}
|
||||
const response = await getSessionApi(api).getSessions({
|
||||
|
||||
34
hooks/useTVAccountActionModal.ts
Normal file
34
hooks/useTVAccountActionModal.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvAccountActionModalAtom } from "@/utils/atoms/tvAccountActionModal";
|
||||
import type {
|
||||
SavedServer,
|
||||
SavedServerAccount,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface ShowAccountActionModalParams {
|
||||
server: SavedServer;
|
||||
account: SavedServerAccount;
|
||||
onLogin: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const useTVAccountActionModal = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const showAccountActionModal = useCallback(
|
||||
(params: ShowAccountActionModalParams) => {
|
||||
store.set(tvAccountActionModalAtom, {
|
||||
server: params.server,
|
||||
account: params.account,
|
||||
onLogin: params.onLogin,
|
||||
onDelete: params.onDelete,
|
||||
});
|
||||
router.push("/tv-account-action-modal");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return { showAccountActionModal };
|
||||
};
|
||||
34
hooks/useTVAccountSelectModal.ts
Normal file
34
hooks/useTVAccountSelectModal.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvAccountSelectModalAtom } from "@/utils/atoms/tvAccountSelectModal";
|
||||
import type {
|
||||
SavedServer,
|
||||
SavedServerAccount,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface ShowAccountSelectModalParams {
|
||||
server: SavedServer;
|
||||
onAccountAction: (account: SavedServerAccount) => void;
|
||||
onAddAccount: () => void;
|
||||
onDeleteServer: () => void;
|
||||
}
|
||||
|
||||
export const useTVAccountSelectModal = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const showAccountSelectModal = useCallback(
|
||||
(params: ShowAccountSelectModalParams) => {
|
||||
store.set(tvAccountSelectModalAtom, {
|
||||
server: params.server,
|
||||
onAccountAction: params.onAccountAction,
|
||||
onAddAccount: params.onAddAccount,
|
||||
onDeleteServer: params.onDeleteServer,
|
||||
});
|
||||
router.push("/tv-account-select-modal");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return { showAccountSelectModal };
|
||||
};
|
||||
88
hooks/useTVBackHandler.ts
Normal file
88
hooks/useTVBackHandler.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useSegments } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
disableTVMenuKeyInterception,
|
||||
enableTVMenuKeyInterception,
|
||||
useTVBackPress,
|
||||
} from "./useTVBackPress";
|
||||
|
||||
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
||||
|
||||
/** All tab route names used in the bottom tab navigator. */
|
||||
export const TAB_ROUTES = [
|
||||
"(home)",
|
||||
"(search)",
|
||||
"(favorites)",
|
||||
"(libraries)",
|
||||
"(watchlists)",
|
||||
"(custom-links)",
|
||||
"(settings)",
|
||||
] as const;
|
||||
|
||||
export type TabRoute = (typeof TAB_ROUTES)[number];
|
||||
|
||||
/** Check if a segment string is a tab route. */
|
||||
export function isTabRoute(s: string): s is TabRoute {
|
||||
return (TAB_ROUTES as readonly string[]).includes(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're at the root of a tab
|
||||
*/
|
||||
function isAtTabRoot(segments: string[]): boolean {
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
return isTabRoute(lastSegment) || lastSegment === "index";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tab name from segments
|
||||
*/
|
||||
function getCurrentTab(segments: string[]): TabRoute | undefined {
|
||||
return segments.find(isTabRoute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps tvOS menu key interception disabled on the home tab root so the system
|
||||
* can apply its native app-exit behavior. Other routes can opt into
|
||||
* interception when they need JS-owned back handling.
|
||||
*/
|
||||
export function useTVHomeBackHandler() {
|
||||
const segments = useSegments();
|
||||
|
||||
const currentTab = getCurrentTab(segments);
|
||||
const atTabRoot = isAtTabRoot(segments);
|
||||
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) return;
|
||||
|
||||
if (isOnHomeRoot) {
|
||||
disableTVMenuKeyInterception();
|
||||
return;
|
||||
}
|
||||
|
||||
enableTVMenuKeyInterception();
|
||||
}, [isOnHomeRoot]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles back press at a non-Home tab root on Android TV by navigating to Home.
|
||||
*
|
||||
* Without NativeTabs, the Stack navigator used for the Android TV nav bar has no
|
||||
* built-in tab-level back handling — pressing back at a tab root would pop the
|
||||
* Stack entirely and exit the tab navigator. This hook intercepts that and routes
|
||||
* to Home instead.
|
||||
*/
|
||||
export function useTVTabRootBackHandler(
|
||||
onNavigateHome: () => void,
|
||||
isAtTabRoot: boolean,
|
||||
currentTab: string | undefined,
|
||||
) {
|
||||
useTVBackPress(() => {
|
||||
if (!Platform.isTV || Platform.OS !== "android") return false;
|
||||
if (!isAtTabRoot || currentTab === "(home)") return false;
|
||||
onNavigateHome();
|
||||
return true;
|
||||
}, [isAtTabRoot, currentTab, onNavigateHome]);
|
||||
}
|
||||
72
hooks/useTVBackPress.ts
Normal file
72
hooks/useTVBackPress.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { type DependencyList, useEffect } from "react";
|
||||
import { BackHandler, Platform } from "react-native";
|
||||
|
||||
type TVBackPressHandler = () => boolean | null | undefined;
|
||||
|
||||
let TVEventControl: {
|
||||
enableTVMenuKey: () => void;
|
||||
disableTVMenuKey: () => void;
|
||||
} | null = null;
|
||||
|
||||
if (Platform.isTV) {
|
||||
try {
|
||||
TVEventControl = require("react-native").TVEventControl;
|
||||
} catch {
|
||||
TVEventControl = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function enableTVMenuKeyInterception() {
|
||||
if (Platform.isTV && TVEventControl) {
|
||||
TVEventControl.enableTVMenuKey();
|
||||
}
|
||||
}
|
||||
|
||||
export function disableTVMenuKeyInterception() {
|
||||
if (Platform.isTV && TVEventControl) {
|
||||
TVEventControl.disableTVMenuKey();
|
||||
}
|
||||
}
|
||||
|
||||
export function useTVMenuKeyInterception(enabled = true) {
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) return;
|
||||
|
||||
if (enabled) {
|
||||
enableTVMenuKeyInterception();
|
||||
return;
|
||||
}
|
||||
|
||||
disableTVMenuKeyInterception();
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to TV back presses through React Native's BackHandler.
|
||||
*
|
||||
* On Android TV this handles the hardware back button. On tvOS,
|
||||
* react-native-tvos maps the Apple TV menu button to the same API when menu key
|
||||
* interception is enabled.
|
||||
*
|
||||
* @see https://reactnative.dev/docs/backhandler
|
||||
*/
|
||||
export function useTVBackPress(
|
||||
handler: TVBackPressHandler,
|
||||
deps: DependencyList,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) return;
|
||||
|
||||
// BackHandler is the shared back/menu surface for TV platforms:
|
||||
// Android TV sends hardware back here, and react-native-tvos sends menu
|
||||
// here when menu key interception is enabled.
|
||||
const subscription = BackHandler.addEventListener(
|
||||
"hardwareBackPress",
|
||||
handler,
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
17
hooks/useTVEventHandler.ts
Normal file
17
hooks/useTVEventHandler.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { HWEvent } from "react-native";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
type UseTVEventHandler = (callback: (evt: HWEvent) => void) => void;
|
||||
|
||||
let tvEventHandler: UseTVEventHandler = () => {};
|
||||
|
||||
if (Platform.isTV) {
|
||||
try {
|
||||
tvEventHandler = require("react-native")
|
||||
.useTVEventHandler as UseTVEventHandler;
|
||||
} catch {
|
||||
tvEventHandler = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
export const useTVEventHandler = tvEventHandler;
|
||||
82
hooks/useTVItemActionModal.ts
Normal file
82
hooks/useTVItemActionModal.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "react-native";
|
||||
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
|
||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||
|
||||
export const useTVItemActionModal = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { markItemPlayed, markItemUnplayed } = usePlaybackManager();
|
||||
const invalidatePlaybackProgressCache = useInvalidatePlaybackProgressCache();
|
||||
|
||||
const showItemActions = useCallback(
|
||||
(item: BaseItemDto) => {
|
||||
const isPlayed = item.UserData?.Played ?? false;
|
||||
const itemTitle =
|
||||
item.Type === "Episode"
|
||||
? `${item.SeriesName} - ${item.Name}`
|
||||
: (item.Name ?? "");
|
||||
|
||||
const actionLabel = isPlayed
|
||||
? t("item_card.mark_unplayed")
|
||||
: t("item_card.mark_played");
|
||||
|
||||
Alert.alert(itemTitle, undefined, [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: actionLabel,
|
||||
onPress: async () => {
|
||||
if (!item.Id) return;
|
||||
|
||||
// Optimistic update
|
||||
queryClient.setQueriesData<BaseItemDto | null | undefined>(
|
||||
{ queryKey: ["item", item.Id] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
UserData: {
|
||||
...old.UserData,
|
||||
Played: !isPlayed,
|
||||
PlaybackPositionTicks: 0,
|
||||
PlayedPercentage: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
if (!isPlayed) {
|
||||
await markItemPlayed(item.Id);
|
||||
} else {
|
||||
await markItemUnplayed(item.Id);
|
||||
}
|
||||
} catch {
|
||||
// Revert on failure
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
});
|
||||
} finally {
|
||||
await invalidatePlaybackProgressCache();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["item", item.Id],
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
[
|
||||
t,
|
||||
queryClient,
|
||||
markItemPlayed,
|
||||
markItemUnplayed,
|
||||
invalidatePlaybackProgressCache,
|
||||
],
|
||||
);
|
||||
|
||||
return { showItemActions };
|
||||
};
|
||||
38
hooks/useTVOptionModal.ts
Normal file
38
hooks/useTVOptionModal.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
type TVOptionItem,
|
||||
tvOptionModalAtom,
|
||||
} from "@/utils/atoms/tvOptionModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface ShowOptionsParams<T> {
|
||||
title: string;
|
||||
options: TVOptionItem<T>[];
|
||||
onSelect: (value: T) => void;
|
||||
cardWidth?: number;
|
||||
cardHeight?: number;
|
||||
deferApplyUntilDismissed?: boolean;
|
||||
}
|
||||
|
||||
export const useTVOptionModal = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const showOptions = useCallback(
|
||||
<T>(params: ShowOptionsParams<T>) => {
|
||||
// Use store.set for synchronous update before navigation
|
||||
store.set(tvOptionModalAtom, {
|
||||
title: params.title,
|
||||
options: params.options,
|
||||
onSelect: params.onSelect,
|
||||
cardWidth: params.cardWidth,
|
||||
cardHeight: params.cardHeight,
|
||||
deferApplyUntilDismissed: params.deferApplyUntilDismissed,
|
||||
});
|
||||
router.push("/(auth)/tv-option-modal");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return { showOptions };
|
||||
};
|
||||
34
hooks/useTVRequestModal.ts
Normal file
34
hooks/useTVRequestModal.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvRequestModalAtom } from "@/utils/atoms/tvRequestModal";
|
||||
import type { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface ShowRequestModalParams {
|
||||
requestBody: MediaRequestBody;
|
||||
title: string;
|
||||
id: number;
|
||||
mediaType: MediaType;
|
||||
onRequested: () => void;
|
||||
}
|
||||
|
||||
export const useTVRequestModal = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const showRequestModal = useCallback(
|
||||
(params: ShowRequestModalParams) => {
|
||||
store.set(tvRequestModalAtom, {
|
||||
requestBody: params.requestBody,
|
||||
title: params.title,
|
||||
id: params.id,
|
||||
mediaType: params.mediaType,
|
||||
onRequested: params.onRequested,
|
||||
});
|
||||
router.push("/(auth)/tv-request-modal");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return { showRequestModal };
|
||||
};
|
||||
23
hooks/useTVSeasonSelectModal.ts
Normal file
23
hooks/useTVSeasonSelectModal.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import {
|
||||
type TVSeasonSelectModalState,
|
||||
tvSeasonSelectModalAtom,
|
||||
} from "@/utils/atoms/tvSeasonSelectModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
type ShowSeasonSelectModalParams = NonNullable<TVSeasonSelectModalState>;
|
||||
|
||||
export const useTVSeasonSelectModal = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const showSeasonSelectModal = useCallback(
|
||||
(params: ShowSeasonSelectModalParams) => {
|
||||
store.set(tvSeasonSelectModalAtom, params);
|
||||
router.push("/(auth)/tv-season-select-modal");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return { showSeasonSelectModal };
|
||||
};
|
||||
34
hooks/useTVSeriesSeasonModal.ts
Normal file
34
hooks/useTVSeriesSeasonModal.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvSeriesSeasonModalAtom } from "@/utils/atoms/tvSeriesSeasonModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface ShowSeasonModalParams {
|
||||
seasons: Array<{
|
||||
label: string;
|
||||
value: number;
|
||||
selected: boolean;
|
||||
}>;
|
||||
selectedSeasonIndex: number | string;
|
||||
itemId: string;
|
||||
onSeasonSelect: (seasonIndex: number) => void;
|
||||
}
|
||||
|
||||
export const useTVSeriesSeasonModal = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const showSeasonModal = useCallback(
|
||||
(params: ShowSeasonModalParams) => {
|
||||
store.set(tvSeriesSeasonModalAtom, {
|
||||
seasons: params.seasons,
|
||||
selectedSeasonIndex: params.selectedSeasonIndex,
|
||||
itemId: params.itemId,
|
||||
onSeasonSelect: params.onSeasonSelect,
|
||||
});
|
||||
router.push("/(auth)/tv-series-season-modal");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return { showSeasonModal };
|
||||
};
|
||||
40
hooks/useTVSubtitleModal.ts
Normal file
40
hooks/useTVSubtitleModal.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||
import { useCallback } from "react";
|
||||
import type { Track } from "@/components/video-player/controls/types";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface ShowSubtitleModalParams {
|
||||
item: BaseItemDto;
|
||||
mediaSourceId?: string | null;
|
||||
subtitleTracks: Track[];
|
||||
currentSubtitleIndex: number;
|
||||
onDisableSubtitles?: () => void;
|
||||
onServerSubtitleDownloaded?: () => void;
|
||||
onLocalSubtitleDownloaded?: (path: string) => void;
|
||||
refreshSubtitleTracks?: () => Promise<Track[]>;
|
||||
}
|
||||
|
||||
export const useTVSubtitleModal = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const showSubtitleModal = useCallback(
|
||||
(params: ShowSubtitleModalParams) => {
|
||||
store.set(tvSubtitleModalAtom, {
|
||||
item: params.item,
|
||||
mediaSourceId: params.mediaSourceId,
|
||||
subtitleTracks: params.subtitleTracks,
|
||||
currentSubtitleIndex: params.currentSubtitleIndex,
|
||||
onDisableSubtitles: params.onDisableSubtitles,
|
||||
onServerSubtitleDownloaded: params.onServerSubtitleDownloaded,
|
||||
onLocalSubtitleDownloaded: params.onLocalSubtitleDownloaded,
|
||||
refreshSubtitleTracks: params.refreshSubtitleTracks,
|
||||
});
|
||||
router.push("/(auth)/tv-subtitle-modal");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return { showSubtitleModal };
|
||||
};
|
||||
225
hooks/useTVThemeMusic.ts
Normal file
225
hooks/useTVThemeMusic.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { getLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
type AudioPlayer,
|
||||
createAudioPlayer,
|
||||
setAudioModeAsync,
|
||||
} from "expo-audio";
|
||||
import { useAtom } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useSettings } from "@/utils/atoms/settings";
|
||||
|
||||
const TARGET_VOLUME = 0.3;
|
||||
const FADE_IN_DURATION = 2000;
|
||||
const FADE_OUT_DURATION = 1000;
|
||||
const FADE_STEP_MS = 50;
|
||||
|
||||
/**
|
||||
* Smoothly transitions audio volume from `from` to `to` over `duration` ms.
|
||||
* Returns a cleanup function that cancels the fade.
|
||||
*/
|
||||
function fadeVolume(
|
||||
player: AudioPlayer,
|
||||
from: number,
|
||||
to: number,
|
||||
duration: number,
|
||||
): { promise: Promise<void>; cancel: () => void } {
|
||||
let cancelled = false;
|
||||
const cancel = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
const steps = Math.max(1, Math.floor(duration / FADE_STEP_MS));
|
||||
const delta = (to - from) / steps;
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
let current = from;
|
||||
let step = 0;
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled || step >= steps) {
|
||||
if (!cancelled) {
|
||||
player.volume = to;
|
||||
}
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
step++;
|
||||
current += delta;
|
||||
player.volume = Math.max(0, Math.min(1, current));
|
||||
if (!cancelled) {
|
||||
setTimeout(tick, FADE_STEP_MS);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
});
|
||||
|
||||
return { promise, cancel };
|
||||
}
|
||||
|
||||
// --- Module-level singleton state ---
|
||||
let sharedPlayer: AudioPlayer | null = null;
|
||||
let currentSongId: string | null = null;
|
||||
let ownerCount = 0;
|
||||
let activeFade: { cancel: () => void } | null = null;
|
||||
let cleanupPromise: Promise<void> | null = null;
|
||||
|
||||
/** Fade out, stop, and release the shared player. */
|
||||
async function teardownSharedPlayer(): Promise<void> {
|
||||
const player = sharedPlayer;
|
||||
if (!player) return;
|
||||
|
||||
activeFade?.cancel();
|
||||
activeFade = null;
|
||||
|
||||
try {
|
||||
if (player.isLoaded) {
|
||||
const currentVolume = player.volume ?? TARGET_VOLUME;
|
||||
const fade = fadeVolume(player, currentVolume, 0, FADE_OUT_DURATION);
|
||||
activeFade = fade;
|
||||
await fade.promise;
|
||||
activeFade = null;
|
||||
player.pause();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (sharedPlayer === player) {
|
||||
sharedPlayer = null;
|
||||
currentSongId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin cleanup idempotently; returns the shared promise. */
|
||||
function beginCleanup(): Promise<void> {
|
||||
if (!cleanupPromise) {
|
||||
cleanupPromise = teardownSharedPlayer().finally(() => {
|
||||
cleanupPromise = null;
|
||||
});
|
||||
}
|
||||
return cleanupPromise;
|
||||
}
|
||||
|
||||
export function useTVThemeMusic(itemId: string | undefined) {
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const { settings } = useSettings();
|
||||
|
||||
const enabled =
|
||||
Platform.isTV &&
|
||||
!!api &&
|
||||
!!user?.Id &&
|
||||
!!itemId &&
|
||||
settings.tvThemeMusicEnabled;
|
||||
|
||||
// Fetch theme songs
|
||||
const { data: themeSongs } = useQuery({
|
||||
queryKey: ["themeSongs", itemId],
|
||||
queryFn: async () => {
|
||||
const result = await getLibraryApi(api!).getThemeSongs({
|
||||
itemId: itemId!,
|
||||
userId: user!.Id!,
|
||||
inheritFromParent: true,
|
||||
});
|
||||
return result.data;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Load and play audio when theme songs are available and enabled
|
||||
useEffect(() => {
|
||||
if (!enabled || !themeSongs?.Items?.length || !api) {
|
||||
return;
|
||||
}
|
||||
|
||||
const themeItem = themeSongs.Items[0];
|
||||
const songId = themeItem.Id!;
|
||||
|
||||
ownerCount++;
|
||||
let mounted = true;
|
||||
|
||||
const startPlayback = async () => {
|
||||
// If the same song is already playing, keep it going
|
||||
if (currentSongId === songId && sharedPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a different song is playing (or cleanup is in progress), tear it down first
|
||||
if (sharedPlayer || cleanupPromise) {
|
||||
activeFade?.cancel();
|
||||
activeFade = null;
|
||||
await beginCleanup();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
const player = createAudioPlayer(null);
|
||||
sharedPlayer = player;
|
||||
currentSongId = songId;
|
||||
|
||||
try {
|
||||
await setAudioModeAsync({
|
||||
playsInSilentMode: true,
|
||||
shouldPlayInBackground: false,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
UserId: user!.Id!,
|
||||
DeviceId: api.deviceInfo.id ?? "",
|
||||
MaxStreamingBitrate: "140000000",
|
||||
Container: "mp3,aac,m4a|aac,m4b|aac,flac,wav",
|
||||
TranscodingContainer: "mp4",
|
||||
TranscodingProtocol: "http",
|
||||
AudioCodec: "aac",
|
||||
ApiKey: api.accessToken ?? "",
|
||||
EnableRedirection: "true",
|
||||
EnableRemoteMedia: "false",
|
||||
});
|
||||
const url = `${api.basePath}/Audio/${themeItem.Id}/universal?${params.toString()}`;
|
||||
player.replace({ uri: url });
|
||||
|
||||
if (!mounted || sharedPlayer !== player) {
|
||||
player.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
player.loop = true;
|
||||
player.volume = 0;
|
||||
player.play();
|
||||
|
||||
if (mounted && sharedPlayer === player) {
|
||||
const fade = fadeVolume(player, 0, TARGET_VOLUME, FADE_IN_DURATION);
|
||||
activeFade = fade;
|
||||
await fade.promise;
|
||||
activeFade = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Theme music playback error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
startPlayback();
|
||||
|
||||
// Cleanup: decrement owner count, defer teardown check
|
||||
return () => {
|
||||
mounted = false;
|
||||
ownerCount--;
|
||||
|
||||
// Defer the check so React can finish processing both unmount + mount
|
||||
// in the same commit. If another instance mounts (same song), ownerCount
|
||||
// will be back to >0 and we skip teardown entirely.
|
||||
setTimeout(() => {
|
||||
if (ownerCount === 0) {
|
||||
beginCleanup();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
}, [enabled, themeSongs, api]);
|
||||
}
|
||||
42
hooks/useTVUserSwitchModal.ts
Normal file
42
hooks/useTVUserSwitchModal.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCallback } from "react";
|
||||
import useRouter from "@/hooks/useAppRouter";
|
||||
import { tvUserSwitchModalAtom } from "@/utils/atoms/tvUserSwitchModal";
|
||||
import type {
|
||||
SavedServer,
|
||||
SavedServerAccount,
|
||||
} from "@/utils/secureCredentials";
|
||||
import { store } from "@/utils/store";
|
||||
|
||||
interface UseTVUserSwitchModalOptions {
|
||||
onAccountSelect: (account: SavedServerAccount) => void;
|
||||
}
|
||||
|
||||
export function useTVUserSwitchModal() {
|
||||
const router = useRouter();
|
||||
|
||||
const showUserSwitchModal = useCallback(
|
||||
(
|
||||
server: SavedServer,
|
||||
currentUserId: string,
|
||||
options: UseTVUserSwitchModalOptions,
|
||||
) => {
|
||||
// Need at least 2 accounts (current + at least one other)
|
||||
if (server.accounts.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.set(tvUserSwitchModalAtom, {
|
||||
serverUrl: server.address,
|
||||
serverName: server.name || server.address,
|
||||
accounts: server.accounts,
|
||||
currentUserId,
|
||||
onAccountSelect: options.onAccountSelect,
|
||||
});
|
||||
|
||||
router.push("/(auth)/tv-user-switch-modal");
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return { showUserSwitchModal };
|
||||
}
|
||||
@@ -177,6 +177,9 @@ export const useAddToWatchlist = () => {
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
@@ -235,6 +238,9 @@ export const useRemoveFromWatchlist = () => {
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlists"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["streamystats", "watchlist", variables.watchlistId],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Location from "expo-location";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { getSSID } from "@/modules/wifi-ssid";
|
||||
|
||||
export type PermissionStatus =
|
||||
@@ -15,13 +15,28 @@ export interface UseWifiSSIDReturn {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function mapLocationStatus(
|
||||
status: Location.PermissionStatus,
|
||||
): PermissionStatus {
|
||||
// WiFi SSID is not available on tvOS
|
||||
if (Platform.isTV) {
|
||||
// Export a stub hook for tvOS
|
||||
module.exports = {
|
||||
useWifiSSID: (): UseWifiSSIDReturn => ({
|
||||
ssid: null,
|
||||
permissionStatus: "unavailable" as PermissionStatus,
|
||||
requestPermission: async () => false,
|
||||
isLoading: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Only import Location on non-TV platforms
|
||||
const Location = Platform.isTV ? null : require("expo-location");
|
||||
|
||||
function mapLocationStatus(status: number | undefined): PermissionStatus {
|
||||
if (!Location) return "unavailable";
|
||||
switch (status) {
|
||||
case Location.PermissionStatus.GRANTED:
|
||||
case Location.PermissionStatus?.GRANTED:
|
||||
return "granted";
|
||||
case Location.PermissionStatus.DENIED:
|
||||
case Location.PermissionStatus?.DENIED:
|
||||
return "denied";
|
||||
default:
|
||||
return "undetermined";
|
||||
@@ -30,17 +45,23 @@ function mapLocationStatus(
|
||||
|
||||
export function useWifiSSID(): UseWifiSSIDReturn {
|
||||
const [ssid, setSSID] = useState<string | null>(null);
|
||||
const [permissionStatus, setPermissionStatus] =
|
||||
useState<PermissionStatus>("undetermined");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [permissionStatus, setPermissionStatus] = useState<PermissionStatus>(
|
||||
Platform.isTV ? "unavailable" : "undetermined",
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(!Platform.isTV);
|
||||
|
||||
const fetchSSID = useCallback(async () => {
|
||||
if (Platform.isTV) return;
|
||||
const result = await getSSID();
|
||||
console.log("[WiFi Debug] Native module SSID:", result);
|
||||
setSSID(result);
|
||||
}, []);
|
||||
|
||||
const requestPermission = useCallback(async (): Promise<boolean> => {
|
||||
if (Platform.isTV || !Location) {
|
||||
setPermissionStatus("unavailable");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
const newStatus = mapLocationStatus(status);
|
||||
@@ -58,6 +79,11 @@ export function useWifiSSID(): UseWifiSSIDReturn {
|
||||
}, [fetchSSID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV || !Location) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -79,6 +105,8 @@ export function useWifiSSID(): UseWifiSSIDReturn {
|
||||
|
||||
// Refresh SSID when permission status changes to granted
|
||||
useEffect(() => {
|
||||
if (Platform.isTV) return;
|
||||
|
||||
if (permissionStatus === "granted") {
|
||||
fetchSSID();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user