diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx index 81f123bf..25942472 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites)/items/page.tsx @@ -1,3 +1,4 @@ +import { ItemFields } from "@jellyfin/sdk/lib/generated-client/models"; import { useLocalSearchParams } from "expo-router"; import type React from "react"; import { useEffect } from "react"; @@ -20,7 +21,11 @@ const Page: React.FC = () => { const { offline } = useLocalSearchParams() as { offline?: string }; const isOffline = offline === "true"; - const { data: item, isError } = useItemQuery(id, isOffline); + const { data: item, isError } = useItemQuery(id, false, undefined, [ + ItemFields.MediaSources, + ItemFields.MediaSourceCount, + ItemFields.MediaStreams, + ]); const opacity = useSharedValue(1); const animatedStyle = useAnimatedStyle(() => { diff --git a/components/ItemContent.tsx b/components/ItemContent.tsx index d66a7e5c..4c537e96 100644 --- a/components/ItemContent.tsx +++ b/components/ItemContent.tsx @@ -12,6 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { type Bitrate } from "@/components/BitrateSelector"; import { ItemImage } from "@/components/common/ItemImage"; import { DownloadSingleItem } from "@/components/DownloadItem"; +import { MediaSourceButton } from "@/components/MediaSourceButton"; import { OverviewText } from "@/components/OverviewText"; import { ParallaxScrollView } from "@/components/ParallaxPage"; // const PlayButton = !Platform.isTV ? require("@/components/PlayButton") : null; @@ -23,19 +24,16 @@ import { CurrentSeries } from "@/components/series/CurrentSeries"; import { SeasonEpisodesCarousel } from "@/components/series/SeasonEpisodesCarousel"; import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings"; import { useImageColorsReturn } from "@/hooks/useImageColorsReturn"; +import { useItemQuery } from "@/hooks/useItemQuery"; import { useOrientation } from "@/hooks/useOrientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { useSettings } from "@/utils/atoms/settings"; import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById"; import { AddToFavorites } from "./AddToFavorites"; -import { BitrateSheet } from "./BitRateSheet"; import { ItemHeader } from "./ItemHeader"; -import { ItemTechnicalDetails } from "./ItemTechnicalDetails"; -import { MediaSourceSheet } from "./MediaSourceSheet"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; -import { TrackSheet } from "./TrackSheet"; const Chromecast = !Platform.isTV ? require("./Chromecast") : null; @@ -70,6 +68,9 @@ export const ItemContent: React.FC = React.memo( SelectedOptions | undefined >(undefined); + // preload media sources + useItemQuery(item.Id, false, undefined, []); + const { defaultAudioIndex, defaultBitrate, @@ -201,76 +202,27 @@ export const ItemContent: React.FC = React.memo( } > - + - {item.Type !== "Program" && !Platform.isTV && !isOffline && ( - - - setSelectedOptions( - (prev) => prev && { ...prev, bitrate: val }, - ) - } - selected={selectedOptions.bitrate} - /> - + + + {!isOffline && ( + - setSelectedOptions( - (prev) => - prev && { - ...prev, - mediaSource: val, - }, - ) - } - selected={selectedOptions.mediaSource} + colors={itemColors} /> - { - setSelectedOptions( - (prev) => - prev && { - ...prev, - audioIndex: val, - }, - ); - }} - selected={selectedOptions.audioIndex} - /> - - setSelectedOptions( - (prev) => - prev && { - ...prev, - subtitleIndex: val, - }, - ) - } - selected={selectedOptions.subtitleIndex} - /> - - )} - - + )} + - {item.Type === "Episode" && ( = React.memo( /> )} - {!isOffline && ( - - )} {item.Type !== "Program" && ( diff --git a/components/MediaSourceButton.tsx b/components/MediaSourceButton.tsx new file mode 100644 index 00000000..fcef6b8f --- /dev/null +++ b/components/MediaSourceButton.tsx @@ -0,0 +1,203 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { + BaseItemDto, + MediaSourceInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, TouchableOpacity, View } from "react-native"; +import type { ThemeColors } from "@/hooks/useImageColorsReturn"; +import { useItemQuery } from "@/hooks/useItemQuery"; +import { BITRATES } from "./BitRateSheet"; +import type { SelectedOptions } from "./ItemContent"; +import { type OptionGroup, PlatformDropdown } from "./PlatformDropdown"; + +interface Props extends React.ComponentProps { + item: BaseItemDto; + selectedOptions: SelectedOptions; + setSelectedOptions: React.Dispatch< + React.SetStateAction + >; + colors?: ThemeColors; +} + +export const MediaSourceButton: React.FC = ({ + item, + selectedOptions, + setSelectedOptions, + colors, +}: Props) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const { data: itemWithSources, isLoading } = useItemQuery( + item.Id, + false, + undefined, + [], + ); + + const effectiveColors = colors || { + primary: "#7c3aed", + text: "#000000", + }; + + useEffect(() => { + const firstMediaSource = itemWithSources?.MediaSources?.[0]; + if (!firstMediaSource) return; + setSelectedOptions((prev) => { + if (!prev) return prev; + return { + ...prev, + mediaSource: firstMediaSource, + }; + }); + }, [itemWithSources, setSelectedOptions]); + + const getMediaSourceDisplayName = useCallback((source: MediaSourceInfo) => { + const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); + if (source.Name) return source.Name; + if (videoStream?.DisplayTitle) return videoStream.DisplayTitle; + return `Source ${source.Id}`; + }, []); + + const audioStreams = useMemo( + () => + selectedOptions.mediaSource?.MediaStreams?.filter( + (x) => x.Type === "Audio", + ) || [], + [selectedOptions.mediaSource], + ); + + const subtitleStreams = useMemo( + () => + selectedOptions.mediaSource?.MediaStreams?.filter( + (x) => x.Type === "Subtitle", + ) || [], + [selectedOptions.mediaSource], + ); + + const optionGroups: OptionGroup[] = useMemo(() => { + const groups: OptionGroup[] = []; + + // Bitrate group + groups.push({ + title: t("item_card.quality"), + options: BITRATES.map((bitrate) => ({ + type: "radio" as const, + label: bitrate.key, + value: bitrate, + selected: bitrate.value === selectedOptions.bitrate?.value, + onPress: () => + setSelectedOptions((prev) => prev && { ...prev, bitrate }), + })), + }); + + // Media Source group (only if multiple sources) + if ( + itemWithSources?.MediaSources && + itemWithSources.MediaSources.length > 1 + ) { + groups.push({ + title: t("item_card.video"), + options: itemWithSources.MediaSources.map((source) => ({ + type: "radio" as const, + label: getMediaSourceDisplayName(source), + value: source, + selected: source.Id === selectedOptions.mediaSource?.Id, + onPress: () => + setSelectedOptions( + (prev) => prev && { ...prev, mediaSource: source }, + ), + })), + }); + } + + // Audio track group + if (audioStreams.length > 0) { + groups.push({ + title: t("item_card.audio"), + options: audioStreams.map((stream) => ({ + type: "radio" as const, + label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`, + value: stream.Index, + selected: stream.Index === selectedOptions.audioIndex, + onPress: () => + setSelectedOptions( + (prev) => prev && { ...prev, audioIndex: stream.Index ?? 0 }, + ), + })), + }); + } + + // Subtitle track group (with None option) + if (subtitleStreams.length > 0) { + const noneOption = { + type: "radio" as const, + label: t("common.none"), + value: -1, + selected: selectedOptions.subtitleIndex === -1, + onPress: () => + setSelectedOptions((prev) => prev && { ...prev, subtitleIndex: -1 }), + }; + + const subtitleOptions = subtitleStreams.map((stream) => ({ + type: "radio" as const, + label: stream.DisplayTitle || `${t("common.track")} ${stream.Index}`, + value: stream.Index, + selected: stream.Index === selectedOptions.subtitleIndex, + onPress: () => + setSelectedOptions( + (prev) => prev && { ...prev, subtitleIndex: stream.Index ?? -1 }, + ), + })); + + groups.push({ + title: t("item_card.subtitles"), + options: [noneOption, ...subtitleOptions], + }); + } + + return groups; + }, [ + itemWithSources, + selectedOptions, + audioStreams, + subtitleStreams, + getMediaSourceDisplayName, + t, + setSelectedOptions, + ]); + + const trigger = ( + setOpen(true)} + className='relative' + > + + + {isLoading ? ( + + ) : ( + + )} + + + ); + + return ( + + ); +}; diff --git a/components/PlatformDropdown.tsx b/components/PlatformDropdown.tsx index 867840bd..43ecb6c9 100644 --- a/components/PlatformDropdown.tsx +++ b/components/PlatformDropdown.tsx @@ -184,7 +184,7 @@ const PlatformDropdownComponent = ({ expoUIConfig, bottomSheetConfig, }: PlatformDropdownProps) => { - const { showModal, hideModal } = useGlobalModal(); + const { showModal, hideModal, isVisible } = useGlobalModal(); // Handle controlled open state for Android useEffect(() => { @@ -207,6 +207,14 @@ const PlatformDropdownComponent = ({ } }, [controlledOpen]); + // Watch for modal dismissal on Android (e.g., swipe down, backdrop tap) + // and sync the controlled open state + useEffect(() => { + if (Platform.OS === "android" && controlledOpen === true && !isVisible) { + controlledOnOpenChange?.(false); + } + }, [isVisible, controlledOpen, controlledOnOpenChange]); + if (Platform.OS === "ios") { return ( diff --git a/components/PlayButton.tsx b/components/PlayButton.tsx index a1bf4f6f..6ec287c2 100644 --- a/components/PlayButton.tsx +++ b/components/PlayButton.tsx @@ -358,9 +358,6 @@ export const PlayButton: React.FC = ({ [startColor.value.text, endColor.value.text], ), })); - /** - * ********************* - */ // if (Platform.OS === "ios") // return ( @@ -414,7 +411,7 @@ export const PlayButton: React.FC = ({ accessibilityLabel='Play button' accessibilityHint='Tap to play the media' onPress={onPress} - className={"relative"} + className={"relative flex-1"} > { +// Helper to exclude specific fields +export const excludeFields = (fieldsToExclude: ItemFields[]) => { + return Object.values(ItemFields).filter( + (field) => !fieldsToExclude.includes(field), + ); +}; + +export const useItemQuery = ( + itemId: string | undefined, + isOffline?: boolean, + fields?: ItemFields[], + excludeFields?: ItemFields[], +) => { const [api] = useAtom(apiAtom); const [user] = useAtom(userAtom); const { getDownloadedItemById } = useDownload(); + // Calculate final fields: use excludeFields if provided, otherwise use fields + const finalFields = excludeFields + ? Object.values(ItemFields).filter( + (field) => !excludeFields.includes(field), + ) + : fields; + return useQuery({ - queryKey: ["item", itemId], + queryKey: ["item", itemId, finalFields], queryFn: async () => { + if (!itemId) throw new Error("Item ID is required"); + if (isOffline) { return getDownloadedItemById(itemId)?.item; } - if (!api || !user || !itemId) return null; - const res = await getUserLibraryApi(api).getItem({ - itemId: itemId, - userId: user?.Id, + + if (!api || !user) return null; + + const response = await getItemsApi(api).getItems({ + ids: [itemId], + userId: user.Id, + ...(finalFields && { fields: finalFields }), }); - return res.data; + + return response.data.Items?.[0]; }, - staleTime: 0, + enabled: !!itemId, refetchOnMount: true, refetchOnWindowFocus: true, refetchOnReconnect: true, diff --git a/hooks/useJellyseerr.ts b/hooks/useJellyseerr.ts index 34cc4dfe..9bdd4394 100644 --- a/hooks/useJellyseerr.ts +++ b/hooks/useJellyseerr.ts @@ -512,7 +512,7 @@ export const useJellyseerr = () => { }; const jellyseerrRegion = useMemo( - () => jellyseerrUser?.settings?.discoverRegion || "US", + () => jellyseerrUser?.settings?.region || "US", [jellyseerrUser], ); diff --git a/translations/en.json b/translations/en.json index 99b1b779..bd21767b 100644 --- a/translations/en.json +++ b/translations/en.json @@ -337,7 +337,8 @@ "audio": "Audio", "subtitle": "Subtitle", "play": "Play", - "none": "None" + "none": "None", + "track": "Track" }, "search": { "search": "Search...", @@ -444,6 +445,7 @@ "no_similar_items_found": "No Similar Items Found", "video": "Video", "more_details": "More Details", + "media_options": "Media Options", "quality": "Quality", "audio": "Audio", "subtitles": "Subtitle", diff --git a/utils/jellyseerr b/utils/jellyseerr index fc6a9e95..4401b164 160000 --- a/utils/jellyseerr +++ b/utils/jellyseerr @@ -1 +1 @@ -Subproject commit fc6a9e952ca524fcc2252d4a6eb4f08bb767a9a3 +Subproject commit 4401b16414af604a7372dacac326c38b18ad8555