mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 03:28:27 +01:00
Merge branch 'develop' into sync-subtitle/audio-data
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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 };
|
||||
};
|
||||
67
hooks/useTVBackHandler.ts
Normal file
67
hooks/useTVBackHandler.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useSegments } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
disableTVMenuKeyInterception,
|
||||
enableTVMenuKeyInterception,
|
||||
} from "./useTVBackPress";
|
||||
|
||||
export { enableTVMenuKeyInterception } from "./useTVBackPress";
|
||||
|
||||
/**
|
||||
* Check if we're at the root of a tab
|
||||
*/
|
||||
function isAtTabRoot(segments: string[]): boolean {
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
const tabNames = [
|
||||
"(home)",
|
||||
"(search)",
|
||||
"(favorites)",
|
||||
"(libraries)",
|
||||
"(watchlists)",
|
||||
"(settings)",
|
||||
"(custom-links)",
|
||||
];
|
||||
return tabNames.includes(lastSegment) || lastSegment === "index";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current tab name from segments
|
||||
*/
|
||||
function getCurrentTab(segments: string[]): string | undefined {
|
||||
return segments.find(
|
||||
(s) =>
|
||||
s === "(home)" ||
|
||||
s === "(search)" ||
|
||||
s === "(favorites)" ||
|
||||
s === "(libraries)" ||
|
||||
s === "(watchlists)" ||
|
||||
s === "(settings)" ||
|
||||
s === "(custom-links)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// Get current state
|
||||
const currentTab = getCurrentTab(segments);
|
||||
const atTabRoot = isAtTabRoot(segments);
|
||||
const isOnHomeRoot = atTabRoot && currentTab === "(home)";
|
||||
|
||||
useEffect(() => {
|
||||
if (!Platform.isTV) return;
|
||||
|
||||
if (isOnHomeRoot) {
|
||||
disableTVMenuKeyInterception();
|
||||
return;
|
||||
}
|
||||
|
||||
enableTVMenuKeyInterception();
|
||||
}, [isOnHomeRoot]);
|
||||
}
|
||||
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 };
|
||||
};
|
||||
36
hooks/useTVOptionModal.ts
Normal file
36
hooks/useTVOptionModal.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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,24 @@ 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 +80,11 @@ export function useWifiSSID(): UseWifiSSIDReturn {
|
||||
}, [fetchSSID]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.isTV || !Location) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -79,6 +106,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