Merge branch 'develop' into sync-subtitle/audio-data

This commit is contained in:
Alex Kim
2026-05-31 14:36:11 +10:00
401 changed files with 43001 additions and 6855 deletions

View File

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

View File

@@ -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,
});
};

View File

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

View 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
View 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();
},
};
}

View File

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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